Módulo 13: JDBC: transacciones y patrón DAO
Objetivos del módulo
- Agrupar varias sentencias en una transacción con
commityrollback, y usar puntos de guardado - Entender los niveles de aislamiento, las anomalías que evitan y los bloqueos con
SELECT ... FOR UPDATE - Insertar miles de filas con procesamiento por lotes y medir la diferencia de rendimiento
- Saber por qué hace falta un pool de conexiones y configurar HikariCP como
DataSource - Organizar el acceso a datos con el patrón DAO: interfaz genérica e implementaciones JDBC para
Departamento,Empleado,ProductoyPedido - Crear una excepción propia (
DataAccessException) que oculteSQLExceptiona las capas superiores - Eliminar el código repetido con un
RowMapperfuncional y una plantilla JDBC casera (la idea delJdbcTemplatede Spring) - Separar la lógica de negocio en una capa de servicio que trabaja con DTO y controla las transacciones
- Probar los DAO y el servicio con H2 en memoria y con Testcontainers sobre un MySQL real
- Construir una aplicación de consola por capas sobre EMPRESA: alta de pedidos con sus líneas y actualización de stock en una transacción
- Comparar JDBC con un ORM como antesala de Hibernate
1. Del programa de prueba a la aplicación profesional
En el módulo 12 cada programa abría su conexión, escribía su SQL, recorría su ResultSet e imprimía el resultado, todo en el mismo main. Para aprender está muy bien, pero una aplicación real (una tienda, un ERP, una API) necesita más:
| Problema del código del módulo 12 | Solución que veremos |
|---|---|
| Cada sentencia se confirma sola: un fallo a mitad deja los datos a medias | Transacciones (apartado 2) |
| Insertar miles de filas una a una es lentísimo | Procesamiento por lotes (apartado 3) |
| Abrir una conexión física para cada operación es caro | Pool de conexiones con HikariCP (apartado 4) |
| El SQL está mezclado con la interfaz y la lógica | Patrón DAO y capa de servicio (apartados 5 y 9) |
try-with-resources, setXxx y while (rs.next()) se repiten en cada método | RowMapper y plantilla JDBC (apartados 7 y 8) |
SQLException (comprobada) contamina todas las capas | Excepción propia no comprobada (apartado 6) |
Al final del módulo tendrás una aplicación de pedidos completa sobre la base de datos EMPRESA. Si no tienes MySQL en marcha, sigue el apartado 2 del módulo 12: contenedor mysql-empresa en localhost:3306, base de datos empresa, usuario curso y contraseña curso, con los scripts de ../recursos.
El proyecto Maven del módulo tiene esta estructura:
empresa-pedidos/
├── pom.xml
└── src/
├── main/java/es/curso/empresa/
│ ├── config/ Conexiones (DriverManager) y FuenteDatos (pool HikariCP)
│ ├── demos/ Transferencia, PuntosGuardado, Aislamiento, Lotes, PoolDemo
│ ├── modelo/ records Departamento, Empleado, Producto, Cliente, Pedido, LineaPedido
│ ├── jdbc/ DataAccessException, RowMapper, JdbcPlantilla, GestorTransacciones
│ ├── dao/ interfaces Dao, DepartamentoDao, ProductoDao, PedidoDao... y jdbc/ con las implementaciones
│ ├── servicio/ PedidoService, ReglaNegocioException y dto/
│ └── app/ AppPedidos (menú de consola)
├── main/resources/ db.properties, hikari.properties, simplelogger.properties
└── test/ pruebas con H2 y Testcontainers + copia de los scripts de EMPRESA
Estas son las dependencias del pom.xml, con la propiedad testcontainers.version definida a 2.0.5 (además de maven-compiler-plugin 3.16.0, maven-surefire-plugin 3.6.0 y exec-maven-plugin 3.6.4 con mainClass apuntando a la aplicación):
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Driver de MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>26.7.0</version>
</dependency>
<!-- Pool de conexiones -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>7.1.0</version>
</dependency>
<!-- HikariCP registra sus mensajes con SLF4J: esta es una implementación sencilla -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.20</version>
</dependency>
<!-- Pruebas -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.5.252</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
💡 Consejo: La clase
Conexionesdeconfiges la misma fábrica del módulo 12 (leedb.properties), con un método extraobtener(Properties extra)que permite añadir propiedades del driver. La usaremos en las demostraciones de los apartados 2 y 3; a partir del 4 trabajaremos con el pool.
2. Transacciones en JDBC
📘 Concepto: Una transacción es un conjunto de operaciones que la base de datos trata como una sola unidad: o se aplican todas o no se aplica ninguna. Cumple las propiedades ACID: Atomicidad (todo o nada), Consistencia (de un estado válido a otro válido), aIslamiento (las transacciones concurrentes no se pisan) y Durabilidad (lo confirmado sobrevive a un fallo).
El ejemplo clásico es una transferencia: restar en una cuenta y sumar en otra. Si el programa falla entre las dos operaciones, el dinero desaparece. En EMPRESA lo haremos moviendo presupuesto entre departamentos.
2.1 Autocommit, commit y rollback
Figura 1. En modo autocommit cada sentencia se confirma sola; en una transacción, commit() o rollback() deciden sobre todas a la vez.
Toda Connection nace en modo autocommit: cada sentencia es una transacción que se confirma al terminar. Para agrupar varias:
| Método | Efecto |
|---|---|
con.setAutoCommit(false) | Empieza una transacción: lo siguiente no se confirma hasta que lo digas |
con.commit() | Confirma todos los cambios desde el inicio de la transacción |
con.rollback() | Deshace todos los cambios desde el inicio de la transacción |
con.setAutoCommit(true) | Vuelve al modo normal (si había una transacción abierta, la confirma) |
El esquema correcto es siempre el mismo: desactivar el autocommit, ejecutar, commit() si todo va bien y rollback() en el catch:
/** Versión INCORRECTA: cada UPDATE se confirma por separado (autocommit). */
static void transferirSinTransaccion(Connection con, int origen, int destino, BigDecimal importe)
throws SQLException {
try (PreparedStatement restar = con.prepareStatement(RESTAR);
PreparedStatement sumar = con.prepareStatement(SUMAR)) {
ejecutar(restar, importe, origen);
ejecutar(sumar, importe, destino);
}
}
/** Versión CORRECTA: las dos sentencias forman una unidad (todo o nada). */
static void transferir(Connection con, int origen, int destino, BigDecimal importe)
throws SQLException {
boolean autoCommitPrevio = con.getAutoCommit();
con.setAutoCommit(false); // 1. empieza la transacción
try (PreparedStatement restar = con.prepareStatement(RESTAR);
PreparedStatement sumar = con.prepareStatement(SUMAR)) {
ejecutar(restar, importe, origen);
ejecutar(sumar, importe, destino);
con.commit(); // 2a. todo bien: se confirma
} catch (SQLException e) {
con.rollback(); // 2b. algo falló: se deshace todo
throw e;
} finally {
con.setAutoCommit(autoCommitPrevio); // 3. dejamos la conexión como estaba
}
}
private static void ejecutar(PreparedStatement ps, BigDecimal importe, int deptId) throws SQLException {
ps.setBigDecimal(1, importe);
ps.setInt(2, deptId);
if (ps.executeUpdate() != 1) {
throw new SQLException("El departamento " + deptId + " no existe");
}
}
El main de la demostración (Transferencia.java) muestra los presupuestos de los departamentos 10 y 50 tras cada paso: una transferencia correcta, una hacia un departamento que no existe (el 99), otra que dejaría el presupuesto en negativo (lo impide el CHECK presupuesto >= 0 de la tabla) y, por último, la versión sin transacción hacia el departamento 99:
Situación inicial: dept 10 = 500000.00 dept 50 = 200000.00
Tras transferir 50000 de 10 a 50: dept 10 = 450000.00 dept 50 = 250000.00
Error: El departamento 99 no existe -> ROLLBACK
Tras el intento fallido (con tx): dept 10 = 450000.00 dept 50 = 250000.00
Error: Check constraint 'ck_dep_presupuesto' is violated. -> ROLLBACK
Tras el intento sin saldo (con tx): dept 10 = 450000.00 dept 50 = 250000.00
Error: El departamento 99 no existe (sin transacción)
¡Han desaparecido 50000 €!: dept 10 = 400000.00 dept 50 = 250000.00
Restaurado: dept 10 = 500000.00 dept 50 = 200000.00
Con transacción, los dos intentos fallidos no cambian nada: el rollback() deshace también el primer UPDATE, que sí había funcionado. Sin transacción, el primer UPDATE queda confirmado y 50 000 € se esfuman. El programa termina restaurando los valores originales.
⚠️ Importante: Recuerda tres detalles del método
transferir: elrollback()va en elcatch(y después se relanza la excepción para que quien llamó se entere), elfinallydevuelve la conexión a su modo anterior y la comprobaciónexecuteUpdate() != 1convierte en error unUPDATEque no encontró la fila. Para la base de datos unUPDATEde 0 filas no es un error: para tu negocio, sí.
2.2 Puntos de guardado (savepoints)
A veces no quieres deshacerlo todo, sino solo una parte. Un savepoint marca un punto dentro de la transacción al que puedes volver con rollback(savepoint). En este ejemplo se crea un pedido con dos líneas y se intenta añadir una tercera línea “de regalo” con un producto sin stock; si falla, se deshace solo el regalo y el pedido sigue adelante:
Figura 2. rollback(savepoint) deshace solo lo posterior al punto de guardado.
con.setAutoCommit(false);
int pedidoId;
try {
try (PreparedStatement ps = con.prepareStatement(
"INSERT INTO pedidos (cliente_id, empleado_id) VALUES (?, ?)",
Statement.RETURN_GENERATED_KEYS)) {
ps.setInt(1, 1003);
ps.setInt(2, 111);
ps.executeUpdate();
try (ResultSet rs = ps.getGeneratedKeys()) {
rs.next();
pedidoId = rs.getInt(1);
}
}
anadirLinea(con, pedidoId, 1, 1, 1); // Portátil UltraBook 14
anadirLinea(con, pedidoId, 2, 6, 1); // Ratón inalámbrico
System.out.println("Pedido " + pedidoId + " con 2 líneas (aún sin confirmar)");
// Línea opcional de regalo: si falla, NO queremos perder el pedido entero
Savepoint antesDelRegalo = con.setSavepoint("regalo");
try {
anadirLinea(con, pedidoId, 3, 14, 1); // Altavoz Inteligente: stock 0
System.out.println("Regalo añadido");
} catch (SQLException e) {
System.out.println("Regalo sin stock (" + e.getMessage() + ")");
con.rollback(antesDelRegalo); // deshace SOLO lo posterior al savepoint
System.out.println("ROLLBACK TO SAVEPOINT regalo");
}
con.commit();
System.out.println("COMMIT del pedido " + pedidoId);
} catch (SQLException e) {
con.rollback();
throw e;
}
Pedido 5025 con 2 líneas (aún sin confirmar)
Regalo sin stock (Check constraint 'ck_pro_stock' is violated.)
ROLLBACK TO SAVEPOINT regalo
COMMIT del pedido 5025
línea 1: Portátil UltraBook 14 x1 a 899.00 € (stock restante 24)
línea 2: Ratón inalámbrico x1 a 19.90 € (stock restante 199)
El método anadirLinea inserta la línea copiando el precio actual del producto (INSERT ... SELECT) y descuenta el stock; con el Altavoz Inteligente (stock 0) el UPDATE viola CHECK (stock >= 0). Al final el programa borra el pedido de prueba (sus líneas se borran en cascada) y repone el stock.
2.3 Niveles de aislamiento
Cuando dos usuarios trabajan a la vez, ¿qué ve cada uno de lo que el otro todavía no ha confirmado? Lo decide el nivel de aislamiento de la transacción. El estándar SQL define tres anomalías y cuatro niveles:
Figura 3. Niveles de aislamiento y anomalías que permite cada uno.
| Anomalía | Qué ocurre |
|---|---|
| Lectura sucia (dirty read) | Lees cambios de otra transacción sin confirmar, que quizá se deshagan |
| Lectura no repetible | Lees dos veces la misma fila en tu transacción y obtienes valores distintos porque otro la modificó y confirmó |
| Lectura fantasma (phantom) | Repites una consulta y aparecen filas nuevas que otro insertó y confirmó |
El nivel se cambia con con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED) (y se consulta con getTransactionIsolation()). La demostración usa dos conexiones, A y B, que simulan dos usuarios trabajando sobre el salario de Tomás (empleado 160, 900 €):
/** Lectura sucia: B lee un cambio que A todavía no ha confirmado. */
static void lecturaSucia(int nivelB) throws SQLException {
try (Connection a = Conexiones.obtener(); Connection b = Conexiones.obtener()) {
a.setAutoCommit(false);
b.setTransactionIsolation(nivelB);
actualizar(a, 9999); // A cambia, sin COMMIT
System.out.printf(" [%s] B lee %s mientras A no confirma%n", nombre(nivelB), salario(b));
a.rollback(); // A se arrepiente
}
}
/** Lectura no repetible: B lee dos veces dentro de SU transacción y A confirma entre medias. */
static void lecturaNoRepetible(int nivelB) throws SQLException {
try (Connection a = Conexiones.obtener(); Connection b = Conexiones.obtener()) {
b.setTransactionIsolation(nivelB);
b.setAutoCommit(false);
String primera = salario(b);
actualizar(a, 1000); // A en autocommit: confirma ya
String segunda = salario(b);
b.commit();
System.out.printf(" [%s] B lee %s y luego %s%n", nombre(nivelB), primera, segunda);
actualizar(a, 900); // restauramos
}
}
Nivel por defecto en MySQL: REPEATABLE READ
Lectura sucia:
[READ UNCOMMITTED] B lee 9999.00 mientras A no confirma
[READ COMMITTED] B lee 900.00 mientras A no confirma
Lectura no repetible:
[READ COMMITTED] B lee 900.00 y luego 1000.00
[REPEATABLE READ] B lee 900.00 y luego 900.00
Bloqueo con SELECT ... FOR UPDATE:
B esperó 2.4 s: Lock wait timeout exceeded; try restarting transaction (código 1205, SQLState 40001)
- Con
READ UNCOMMITTED, B ve el 9999 que A nunca llegó a confirmar: una lectura sucia. - Con
READ COMMITTED, B solo ve datos confirmados, pero dentro de su transacción lee 900 y después 1000: lectura no repetible. - Con
REPEATABLE READ(el nivel por defecto de MySQL/InnoDB), B lee siempre 900 dentro de su transacción: InnoDB le sirve una instantánea tomada en su primera lectura.
La última línea muestra un bloqueo: A ejecuta SELECT ... FOR UPDATE, que bloquea la fila hasta el fin de su transacción, y B, que quiere modificarla, espera hasta agotar innodb_lock_wait_timeout (lo pusimos a 2 s; por defecto son 50) y recibe el error 1205. InnoDB comprueba los plazos de espera cada segundo aproximadamente, por eso la espera real es algo mayor.
💡 Consejo:
SELECT ... FOR UPDATEes la forma de evitar que dos transacciones lean el mismo stock, comprueben que hay unidades y vendan las dos la última. La usaremos en el servicio de pedidos (apartado 9). Si dos transacciones se esperan mutuamente (interbloqueo), MySQL aborta una con el error 1213 (Deadlock found), también con SQLState40001: la respuesta correcta es repetir la transacción.
| Nivel | Cuándo usarlo |
|---|---|
READ COMMITTED | Muchas lecturas concurrentes, cuando no te importa que un dato cambie entre dos lecturas (es el valor por defecto en PostgreSQL, Oracle y SQL Server) |
REPEATABLE READ | Valor por defecto de MySQL; informes coherentes dentro de una transacción |
SERIALIZABLE | Máxima seguridad; muchos bloqueos y peor concurrencia. Rara vez compensa |
READ UNCOMMITTED | Prácticamente nunca |
3. Procesamiento por lotes
Insertar 2 000 filas con un executeUpdate() cada una significa 2 000 viajes de ida y vuelta por la red y, en modo autocommit, 2 000 transacciones que el servidor tiene que escribir en disco. Con un lote (batch) acumulas sentencias con addBatch() y las envías juntas con executeBatch():
// 3. Por lotes: se acumulan sentencias y se envían juntas
static void porLotes(Connection con) throws SQLException {
con.setAutoCommit(false);
try (PreparedStatement ps = con.prepareStatement(INSERT)) {
for (int i = 1; i <= FILAS; i++) {
ps.setString(1, "Registro " + i);
ps.setInt(2, i);
ps.addBatch(); // a la cola, todavía no se envía
if (i % TAM_LOTE == 0) {
int[] resultados = ps.executeBatch(); // envía el lote
if (i == TAM_LOTE) {
System.out.println(" executeBatch() devuelve " + resultados.length
+ " resultados; el primero = " + resultados[0]);
}
}
}
ps.executeBatch(); // por si queda un lote incompleto
}
con.commit();
con.setAutoCommit(true);
}
executeBatch() devuelve un int[] con el resultado de cada sentencia del lote: el número de filas afectadas o Statement.SUCCESS_NO_INFO (−2) cuando el driver no puede saberlo. Si una sentencia falla se lanza BatchUpdateException, cuyo getUpdateCounts() indica hasta dónde llegó.
El programa Lotes.java crea una tabla de pruebas, mide cuatro estrategias para insertar 2 000 filas y al final la borra. La cuarta usa la propiedad rewriteBatchedStatements=true de Connector/J, que reescribe el lote como un único INSERT ... VALUES (...), (...), .... Cada estrategia es un método que recibe la conexión, y Estrategia es una interfaz funcional propia (void insertar(Connection con) throws SQLException), así que se pasan como referencias a métodos:
static void medir(String nombre, Properties extra, Estrategia estrategia) throws SQLException {
try (Connection con = Conexiones.obtener(extra)) {
try (Statement st = con.createStatement()) {
st.execute("TRUNCATE TABLE prueba_lotes");
}
long inicio = System.nanoTime();
estrategia.insertar(con);
long ms = (System.nanoTime() - inicio) / 1_000_000;
System.out.printf("%-44s %6d ms%n", nombre, ms);
}
}
public static void main(String[] args) throws SQLException {
try (Connection con = Conexiones.obtener(); Statement st = con.createStatement()) {
st.execute("""
CREATE TABLE IF NOT EXISTS prueba_lotes (
id INT AUTO_INCREMENT PRIMARY KEY,
texto VARCHAR(50),
valor INT)""");
}
Properties normal = new Properties();
Properties reescribir = new Properties();
reescribir.setProperty("rewriteBatchedStatements", "true");
medir("1. Una a una (autocommit)", normal, Lotes::unaAUna);
// ... y lo mismo con las otras tres estrategias
}
Insertando 2000 filas:
1. Una a una (autocommit) 40300 ms
2. Una a una en una transacción 4514 ms
executeBatch() devuelve 500 resultados; el primero = 1
3. Lotes de 500 3632 ms
executeBatch() devuelve 500 resultados; el primero = -2
4. Lotes + rewriteBatchedStatements=true 242 ms
Figura 4. Tiempos medidos al insertar 2 000 filas en MySQL 8.4 (Docker), en escala logarítmica.
Los números absolutos dependen de tu equipo (estos se midieron en un portátil con Docker y otros contenedores trabajando a la vez), pero las proporciones se repiten siempre: lo más caro es confirmar (cada COMMIT obliga a escribir en el disco), después los viajes por la red, y rewriteBatchedStatements=true reduce los dos. Fíjate también en que, con esa propiedad, executeBatch() devuelve −2 (SUCCESS_NO_INFO): MySQL ejecuta una sentencia gigante y no sabe cuántas filas corresponden a cada original.
💡 Consejo: Envía lotes de cientos o pocos miles de filas (aquí, de 500) en lugar de uno enorme: limita la memoria usada y el tamaño de cada paquete. Y mete siempre el lote en una transacción: si la fila 1 500 falla, decides tú si deshacer todo.
4. Pool de conexiones con HikariCP
4.1 Por qué hace falta
Abrir una conexión física es caro: conexión TCP, negociación TLS, autenticación y creación de la sesión en el servidor. En un programa de consola que hace tres consultas da igual, pero una aplicación web atiende cientos de peticiones por segundo. La solución es un pool de conexiones: un conjunto de conexiones ya abiertas que se prestan a quien las pide y se devuelven al terminar.
Figura 5. Un pool de conexiones: getConnection() presta una conexión abierta y close() la devuelve.
El pool se usa a través de la interfaz estándar javax.sql.DataSource: tu código pide dataSource.getConnection() y llama a close() exactamente igual que antes, pero close() ya no cierra el socket, sino que devuelve la conexión al pool. HikariCP es el pool más usado en Java y el que Spring Boot configura por defecto.
4.2 Configuración
Añade com.zaxxer:HikariCP:7.1.0 y una implementación de SLF4J (HikariCP registra sus mensajes con esa API; slf4j-simple los muestra por consola). La configuración puede ir en código (HikariConfig) o en un fichero de propiedades:
# Configuración del pool de conexiones HikariCP
jdbcUrl=jdbc:mysql://localhost:3306/empresa
username=curso
password=curso
poolName=PoolEmpresa
maximumPoolSize=5
minimumIdle=2
connectionTimeout=5000
idleTimeout=600000
maxLifetime=1800000
# Propiedades que se pasan tal cual al driver de MySQL
dataSource.cachePrepStmts=true
dataSource.prepStmtCacheSize=250
dataSource.prepStmtCacheSqlLimit=2048
dataSource.useServerPrepStmts=true
dataSource.rewriteBatchedStatements=true
| Propiedad | Significado | Valor típico |
|---|---|---|
jdbcUrl, username, password | Conexión a la base de datos | — |
maximumPoolSize | Máximo de conexiones (activas + libres) | 10 (valor por defecto) |
minimumIdle | Conexiones libres que intenta mantener | igual que maximumPoolSize (por defecto) |
connectionTimeout | Milisegundos que espera getConnection() antes de lanzar una excepción | 30 000 por defecto |
idleTimeout | Tiempo tras el que se cierra una conexión libre sobrante | 600 000 (10 min) |
maxLifetime | Vida máxima de una conexión; debe ser menor que el wait_timeout del servidor | 1 800 000 (30 min) |
poolName | Nombre que aparece en los mensajes y en JMX | — |
dataSource.* | Propiedades que se pasan tal cual al driver | caché de sentencias preparadas, rewriteBatchedStatements… |
Crear el pool es una línea; como abre conexiones y crea hilos, debe crearse una sola vez al arrancar la aplicación y cerrarse al terminar (HikariDataSource implementa AutoCloseable):
package es.curso.empresa.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
/** Crea el pool de conexiones a partir de hikari.properties (en el classpath). */
public final class FuenteDatos {
private FuenteDatos() { }
public static HikariDataSource crear() {
HikariConfig config = new HikariConfig("/hikari.properties");
return new HikariDataSource(config);
}
}
4.3 La diferencia, medida
PoolDemo hace 50 consultas cortas abriendo y cerrando una conexión con DriverManager cada vez, otras 50 pidiéndolas al pool, y después lanza 20 tareas en hilos virtuales (módulo 07) que ocupan una conexión 200 ms cada una, con un pool de 5:
// 1. Sin pool: cada operación abre y cierra una conexión física
long inicio = System.nanoTime();
for (int i = 0; i < VECES; i++) {
try (Connection con = Conexiones.obtener()) {
consultaCorta(con);
}
}
System.out.printf("Sin pool (DriverManager): %d consultas en %d ms%n",
VECES, (System.nanoTime() - inicio) / 1_000_000);
try (HikariDataSource ds = FuenteDatos.crear()) {
// 2. Con pool: close() devuelve la conexión al pool, no la cierra
inicio = System.nanoTime();
for (int i = 0; i < VECES; i++) {
try (Connection con = ds.getConnection()) {
consultaCorta(con);
}
}
System.out.printf("Con pool (HikariCP): %d consultas en %d ms%n",
VECES, (System.nanoTime() - inicio) / 1_000_000);
// 3. 20 tareas concurrentes (hilos virtuales) compitiendo por 5 conexiones
HikariPoolMXBean pool = ds.getHikariPoolMXBean();
inicio = System.nanoTime();
try (ExecutorService ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 20; i++) {
ejecutor.submit(() -> {
try (Connection con = ds.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT SLEEP(0.2)")) {
ps.executeQuery().close(); // cada tarea ocupa 200 ms la conexión
}
return null;
});
}
Thread.sleep(100);
System.out.printf("Mientras tanto -> activas: %d, libres: %d, esperando: %d, total: %d%n",
pool.getActiveConnections(), pool.getIdleConnections(),
pool.getThreadsAwaitingConnection(), pool.getTotalConnections());
} // close() del executor espera a que terminen todas las tareas
System.out.printf("20 tareas de 200 ms con 5 conexiones: %d ms%n",
(System.nanoTime() - inicio) / 1_000_000);
}
}
}
Sin pool (DriverManager): 50 consultas en 14491 ms
INFO HikariDataSource - PoolEmpresa - Starting...
INFO HikariPool - PoolEmpresa - Added connection com.mysql.cj.jdbc.ConnectionImpl@82de64a
INFO HikariDataSource - PoolEmpresa - Start completed.
Con pool (HikariCP): 50 consultas en 789 ms
Mientras tanto -> activas: 4, libres: 0, esperando: 16, total: 4
20 tareas de 200 ms con 5 conexiones: 1162 ms
INFO HikariDataSource - PoolEmpresa - Shutdown initiated...
INFO HikariDataSource - PoolEmpresa - Shutdown completed.
Las líneas INFO son los mensajes de HikariCP. El pool es unas 18 veces más rápido porque las conexiones ya están abiertas: sin él, cada consulta paga la conexión TCP, el TLS y la autenticación. Y con 20 tareas y 5 conexiones, como mucho trabajan 5 a la vez mientras el resto espera (esperando), así que las 20 tardan unas 4 rondas de 200 ms. Observa que el pool aún no había llegado a las 5 conexiones cuando se tomó la medida: crea las que faltan en segundo plano.
⚠️ Importante: Un pool más grande no es más rápido: la base de datos tiene un número limitado de núcleos y discos. La documentación de HikariCP recomienda pools pequeños (del orden de
núcleos × 2del servidor de base de datos) y que el código devuelva las conexiones cuanto antes. Si una parte del programa olvida cerrar una conexión, el pool se agota y todas las peticiones acaban fallando porconnectionTimeout(ejercicio 4).
5. El patrón DAO
📘 Concepto: Un DAO (Data Access Object) es un objeto que encapsula todo el acceso a los datos de una entidad: el resto de la aplicación le pide “dame el producto 7” o “guarda este departamento” y no sabe nada de SQL, conexiones ni
ResultSet. Lo viste como patrón en el módulo 10 (Repository/DAO); ahora lo implementamos de verdad con JDBC.
Figura 6. Arquitectura en capas de la aplicación de pedidos.
5.1 El modelo
Las entidades son records inmutables. El identificador es Integer y vale null mientras la fila no se ha insertado; un método conId devuelve la copia con la clave generada:
package es.curso.empresa.modelo;
import java.math.BigDecimal;
/** Fila de departamentos. El id es Integer: vale null mientras no se ha insertado. */
public record Departamento(Integer id, String nombre, String localidad, BigDecimal presupuesto) {
public Departamento conId(int nuevoId) {
return new Departamento(nuevoId, nombre, localidad, presupuesto);
}
}
Igual se definen Empleado, Producto, Cliente y EstadoPedido (un enum con los valores del CHECK de la tabla). Un pedido contiene sus líneas, y cada línea sabe calcular su importe:
public record LineaPedido(int numLinea, int productoId, int cantidad, BigDecimal precioUnitario,
BigDecimal descuento) {
/** cantidad x precio x (1 - descuento/100), redondeado a céntimos. */
public BigDecimal importe() {
BigDecimal bruto = precioUnitario.multiply(BigDecimal.valueOf(cantidad));
BigDecimal factor = BigDecimal.ONE.subtract(descuento.movePointLeft(2));
return bruto.multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
}
public record Pedido(Integer id, int clienteId, Integer empleadoId, LocalDate fechaPedido,
LocalDate fechaEnvio, EstadoPedido estado, List<LineaPedido> lineas) {
public Pedido {
lineas = List.copyOf(lineas); // copia defensiva e inmutable
}
public BigDecimal total() {
return lineas.stream().map(LineaPedido::importe).reduce(BigDecimal.ZERO, BigDecimal::add);
}
// ... y conId(int nuevoId)
}
5.2 Una interfaz genérica
Las operaciones CRUD son iguales para todas las entidades, así que las definimos una vez con genéricos (módulo 02) y cada DAO concreto añade sus consultas específicas:
public interface Dao<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
/** Inserta si la entidad no tiene id; si lo tiene, la actualiza. Devuelve la entidad guardada. */
T save(T entidad);
boolean deleteById(ID id);
long count();
}
public interface ProductoDao extends Dao<Producto, Integer> {
List<Producto> findDisponibles();
List<Producto> findStockBajo(int umbral);
/** Lee el producto y BLOQUEA su fila hasta el fin de la transacción (SELECT ... FOR UPDATE). */
Optional<Producto> findByIdParaActualizar(int id);
/** Suma (o resta, si delta es negativo) unidades al stock. */
void modificarStock(int id, int delta);
}
💡 Consejo: Programa siempre contra la interfaz (
ProductoDao), nunca contra la implementación (JdbcProductoDao). El servicio recibirá unProductoDaoen su constructor: en producción será la versión JDBC, en las pruebas podría ser una versión en memoria o un mock de Mockito (módulo 11), y en el módulo 19 la sustituirá un repositorio de Spring Data sin tocar el servicio.
5.3 Una primera implementación “a mano”
Así queda findById de departamentos con lo que sabemos del módulo 12, recibiendo el DataSource por el constructor:
public Optional<Departamento> findById(Integer id) {
String sql = "SELECT dept_id, nombre, localidad, presupuesto FROM departamentos WHERE dept_id = ?";
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapear(rs)) : Optional.empty();
}
} catch (SQLException e) {
throw DataAccessException.traducir("findById", e);
}
}
Funciona, pero cada uno de los seis métodos de la clase (findById, findByNombre, findAll, save, deleteById, count) repite las mismas 10 líneas de conexión, sentencia, parámetros, recorrido y gestión de errores: la clase completa ocupa unas 125 líneas. En los apartados 6 a 8 vamos a eliminar esa repetición.
6. Una excepción propia: DataAccessException
SQLException es una excepción comprobada: si el DAO la deja escapar, el servicio y la interfaz de usuario tienen que declararla o capturarla, y quedan atados a JDBC. Lo profesional es traducirla en el DAO a una excepción no comprobada propia, con subclases para los casos que interesa distinguir:
public class DataAccessException extends RuntimeException {
public DataAccessException(String mensaje, Throwable causa) {
super(mensaje, causa);
}
/** Traduce una SQLException a nuestra jerarquía según su SQLState. */
public static DataAccessException traducir(String operacion, SQLException e) {
String estado = e.getSQLState() == null ? "" : e.getSQLState();
String mensaje = operacion + ": " + e.getMessage();
if (estado.startsWith("23")) { // clase 23: violación de integridad
return new IntegridadDatosException(mensaje, e);
}
if (estado.startsWith("08")) { // clase 08: error de conexión
return new ConexionException(mensaje, e);
}
return new DataAccessException(mensaje, e);
}
/** Clave duplicada, clave ajena inexistente, NOT NULL... */
public static class IntegridadDatosException extends DataAccessException {
public IntegridadDatosException(String mensaje, Throwable causa) {
super(mensaje, causa);
}
}
/** No se puede hablar con la base de datos. */
public static class ConexionException extends DataAccessException {
public ConexionException(String mensaje, Throwable causa) {
super(mensaje, causa);
}
}
}
La traducción usa la clase del SQLState (módulo 12): 23 es una violación de integridad (clave duplicada, clave ajena…) y 08 un problema de conexión. Así el servicio puede escribir catch (IntegridadDatosException e) para avisar de un NIF repetido sin saber nada de MySQL.
📘 Concepto: Spring hace exactamente lo mismo con una jerarquía mucho más completa:
org.springframework.dao.DataAccessException, con subclases comoDuplicateKeyException,DataIntegrityViolationExceptionoCannotAcquireLockException, y traductores que conocen los códigos de cada base de datos. Cuando llegues al módulo 19 reconocerás la idea.
7. RowMapper: el mapeo como función
El único trozo de un método de consulta que cambia de una entidad a otra es cómo se convierte una fila en un objeto. Lo extraemos a una interfaz funcional (módulo 03):
@FunctionalInterface
public interface RowMapper<T> {
T mapRow(ResultSet rs, int numFila) throws SQLException;
/** Composición: aplica una transformación al resultado de este mapper. */
default <R> RowMapper<R> andThen(java.util.function.Function<? super T, ? extends R> despues) {
return (rs, n) -> despues.apply(mapRow(rs, n));
}
}
Cada DAO declara su mapper como una constante con una lambda:
public static final RowMapper<Producto> MAPPER = (rs, n) -> new Producto(
rs.getInt("producto_id"), rs.getString("nombre"), rs.getString("descripcion"),
rs.getInt("categoria_id"), rs.getBigDecimal("precio"), rs.getInt("stock"),
rs.getObject("fecha_alta", LocalDate.class), "S".equals(rs.getString("descatalogado")));
Y el método por defecto andThen permite componerlos, por ejemplo JdbcProductoDao.MAPPER.andThen(Producto::nombre) es un RowMapper<String> que devuelve solo el nombre.
8. Una plantilla JDBC casera
8.1 La parte fija y la parte variable
Figura 7. La plantilla JDBC se encarga de los pasos fijos; el DAO solo aporta el SQL, los parámetros y el RowMapper.
La clase JdbcPlantilla implementa los siete pasos fijos una sola vez. Es el patrón Template Method del módulo 10, pero con callbacks (lambdas) en lugar de herencia:
public class JdbcPlantilla {
private final DataSource dataSource;
public JdbcPlantilla(DataSource dataSource) {
this.dataSource = dataSource;
}
/** Interfaz interna: lo que se hace con la sentencia ya preparada. */
@FunctionalInterface
private interface Accion<T> {
T ejecutar(PreparedStatement ps) throws SQLException;
}
private <T> T ejecutar(String sql, int opcionesClaves, Accion<T> accion, Object... params) {
Connection enTransaccion = GestorTransacciones.conexionActual();
Connection con = null;
try {
con = (enTransaccion != null) ? enTransaccion : dataSource.getConnection();
try (PreparedStatement ps = con.prepareStatement(sql, opcionesClaves)) {
for (int i = 0; i < params.length; i++) {
asignar(ps, i + 1, params[i]);
}
return accion.ejecutar(ps);
}
} catch (SQLException e) {
throw DataAccessException.traducir("Error ejecutando [" + sql + "]", e);
} finally {
// Solo cerramos (devolvemos al pool) la conexión si NO pertenece a una transacción
if (con != null && enTransaccion == null) {
try {
con.close();
} catch (SQLException ignorada) {
// nada útil que hacer
}
}
}
}
private static void asignar(PreparedStatement ps, int indice, Object valor) throws SQLException {
switch (valor) {
case null -> ps.setNull(indice, Types.NULL);
case Enum<?> e -> ps.setString(indice, e.name()); // los enum se guardan como texto
case Boolean b -> ps.setString(indice, b ? "S" : "N"); // convenio de EMPRESA: 'S'/'N'
default -> ps.setObject(indice, valor); // String, Integer, BigDecimal, LocalDate...
}
}
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) {
return ejecutar(sql, Statement.NO_GENERATED_KEYS, ps -> {
List<T> lista = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
int n = 0;
while (rs.next()) {
lista.add(mapper.mapRow(rs, n++));
}
}
return lista;
}, params);
}
public <T> Optional<T> queryForOptional(String sql, RowMapper<T> mapper, Object... params) {
List<T> lista = query(sql, mapper, params);
if (lista.size() > 1) {
throw new DataAccessException("Se esperaba como mucho una fila y hay " + lista.size(), null);
}
return lista.stream().findFirst();
}
public long queryForLong(String sql, Object... params) {
return queryForOptional(sql, (rs, n) -> rs.getLong(1), params).orElse(0L);
}
/** INSERT, UPDATE o DELETE. Devuelve las filas afectadas. */
public int update(String sql, Object... params) {
return ejecutar(sql, Statement.NO_GENERATED_KEYS, PreparedStatement::executeUpdate, params);
}
/** INSERT en una tabla con AUTO_INCREMENT. Devuelve la clave generada. */
public int insertarConClave(String sql, Object... params) {
return ejecutar(sql, Statement.RETURN_GENERATED_KEYS, ps -> {
ps.executeUpdate();
try (ResultSet claves = ps.getGeneratedKeys()) {
if (!claves.next()) {
throw new SQLException("No se ha generado ninguna clave");
}
return claves.getInt(1);
}
}, params);
}
/** Ejecuta la misma sentencia con muchos juegos de parámetros en un lote. */
public int[] batchUpdate(String sql, List<Object[]> juegosDeParametros) {
return ejecutar(sql, Statement.NO_GENERATED_KEYS, ps -> {
for (Object[] params : juegosDeParametros) {
for (int i = 0; i < params.length; i++) {
asignar(ps, i + 1, params[i]);
}
ps.addBatch();
}
return ps.executeBatch();
});
}
}
Fíjate en tres decisiones de diseño:
- Los parámetros llegan como
Object...yasignarusa pattern matching enswitch(Java 21) para tratarnull, losenum(se guardan con su nombre, comoestadode pedidos) y losboolean('S'/'N', el convenio de la columnadescatalogado). - Toda
SQLExceptionsale convertida enDataAccessException, con el SQL en el mensaje para depurar. - La conexión se obtiene del
DataSource… salvo que haya una transacción en curso en el hilo actual, en cuyo caso se usa esa y no se cierra. Lo explica el apartado 8.3.
8.2 El DAO, reescrito
Compara con el apartado 5.3: el DAO completo de departamentos cabe ahora en 60 líneas, y cada método es una línea de SQL más su llamada a la plantilla:
public class JdbcDepartamentoDao implements DepartamentoDao {
private static final String COLUMNAS = "SELECT dept_id, nombre, localidad, presupuesto FROM departamentos ";
static final RowMapper<Departamento> MAPPER = (rs, n) -> new Departamento(
rs.getInt("dept_id"), rs.getString("nombre"),
rs.getString("localidad"), rs.getBigDecimal("presupuesto"));
private final JdbcPlantilla jdbc;
public JdbcDepartamentoDao(JdbcPlantilla jdbc) {
this.jdbc = jdbc;
}
@Override
public Optional<Departamento> findById(Integer id) {
return jdbc.queryForOptional(COLUMNAS + "WHERE dept_id = ?", MAPPER, id);
}
@Override
public Optional<Departamento> findByNombre(String nombre) {
return jdbc.queryForOptional(COLUMNAS + "WHERE nombre = ?", MAPPER, nombre);
}
@Override
public List<Departamento> findAll() {
return jdbc.query(COLUMNAS + "ORDER BY dept_id", MAPPER);
}
@Override
public Departamento save(Departamento d) {
if (d.id() == null) {
int id = jdbc.insertarConClave(
"INSERT INTO departamentos (nombre, localidad, presupuesto) VALUES (?, ?, ?)",
d.nombre(), d.localidad(), d.presupuesto());
return d.conId(id);
}
jdbc.update("UPDATE departamentos SET nombre = ?, localidad = ?, presupuesto = ? WHERE dept_id = ?",
d.nombre(), d.localidad(), d.presupuesto(), d.id());
return d;
}
@Override
public boolean deleteById(Integer id) {
return jdbc.update("DELETE FROM departamentos WHERE dept_id = ?", id) == 1;
}
@Override
public long count() {
return jdbc.queryForLong("SELECT COUNT(*) FROM departamentos");
}
}
Los DAO de empleados, productos y clientes siguen el mismo modelo. El de productos añade la lectura con bloqueo y la modificación de stock, y el de pedidos inserta la cabecera y todas sus líneas en un lote:
public Optional<Producto> findByIdParaActualizar(int id) {
return jdbc.queryForOptional(SELECT + " WHERE producto_id = ? FOR UPDATE", MAPPER, id);
}
public Pedido insertar(Pedido p) {
int id = jdbc.insertarConClave("""
INSERT INTO pedidos (cliente_id, empleado_id, fecha_pedido, fecha_envio, estado)
VALUES (?, ?, ?, ?, ?)""",
p.clienteId(), p.empleadoId(), p.fechaPedido(), p.fechaEnvio(), p.estado());
// Todas las líneas en un único lote
List<Object[]> filas = new ArrayList<>();
for (LineaPedido l : p.lineas()) {
filas.add(new Object[]{id, l.numLinea(), l.productoId(), l.cantidad(),
l.precioUnitario(), l.descuento()});
}
jdbc.batchUpdate("""
INSERT INTO lineas_pedido (pedido_id, num_linea, producto_id, cantidad,
precio_unitario, descuento)
VALUES (?, ?, ?, ?, ?, ?)""", filas);
return p.conId(id);
}
8.3 Transacciones que abarcan varios DAO
Un alta de pedido usa tres DAO (clientes, productos y pedidos), y todo debe ir en la misma transacción, es decir, por la misma conexión. ¿Cómo comparten la conexión sin pasarla como parámetro a cada método? Guardándola en un ThreadLocal (una variable con un valor distinto para cada hilo) mientras dura la transacción:
public class GestorTransacciones {
private static final ThreadLocal<Connection> CONEXION_ACTUAL = new ThreadLocal<>();
private final DataSource dataSource;
public GestorTransacciones(DataSource dataSource) {
this.dataSource = dataSource;
}
/** Conexión de la transacción en curso en este hilo, o null si no hay ninguna. */
static Connection conexionActual() {
return CONEXION_ACTUAL.get();
}
public <T> T enTransaccion(Supplier<T> bloque) {
if (CONEXION_ACTUAL.get() != null) {
return bloque.get(); // ya hay transacción: nos unimos a ella
}
try (Connection con = dataSource.getConnection()) {
con.setAutoCommit(false);
CONEXION_ACTUAL.set(con);
try {
T resultado = bloque.get();
con.commit();
return resultado;
} catch (RuntimeException e) {
con.rollback(); // cualquier excepción deshace la transacción
throw e;
} finally {
CONEXION_ACTUAL.remove();
con.setAutoCommit(true); // la conexión vuelve "limpia" al pool
}
} catch (SQLException e) {
throw DataAccessException.traducir("Gestión de la transacción", e);
}
}
public void enTransaccion(Runnable bloque) {
enTransaccion(() -> {
bloque.run();
return null;
});
}
}
Cualquier RuntimeException dentro del bloque provoca rollback(); si todo termina bien, commit(). Si un método con transacción llama a otro que también la pide, el segundo se une a la del primero. Así funciona, en esencia, la anotación @Transactional de Spring, que verás en los módulos 17 y 19.
⚠️ Importante: Esta solución es didáctica. Spring añade lo que aquí falta: distintos modos de propagación, transacciones de solo lectura, rollback configurable por tipo de excepción, sincronización con varios recursos… No reinventes Spring en un proyecto real; pero entender cómo funciona por dentro te ahorrará muchos disgustos.
9. Capa de servicio y DTO
9.1 DTO: los datos que cruzan las capas
📘 Concepto: Un DTO (Data Transfer Object) es un objeto sin lógica que transporta datos entre capas. No tiene por qué coincidir con una tabla:
NuevoPedidoDtolleva lo que el usuario introduce para crear un pedido (cliente, empleado y líneas con producto y cantidad), yPedidoResumenDtolo que necesita un listado (número de líneas e importe total, calculados con unJOIN).
public record NuevoPedidoDto(int clienteId, Integer empleadoId, List<LineaDto> lineas) {
public NuevoPedidoDto {
if (lineas == null || lineas.isEmpty()) {
throw new IllegalArgumentException("Un pedido necesita al menos una línea");
}
lineas = List.copyOf(lineas);
}
public record LineaDto(int productoId, int cantidad) {
public LineaDto {
if (cantidad <= 0) {
throw new IllegalArgumentException("La cantidad debe ser positiva: " + cantidad);
}
}
}
}
Los constructores compactos de los records validan los datos en cuanto se crean: no puede existir un NuevoPedidoDto sin líneas ni una línea con cantidad 0.
9.2 El servicio de pedidos
El servicio contiene las reglas de negocio y decide los límites de cada transacción. No conoce SQL ni Connection: solo interfaces DAO, DTO y el gestor de transacciones, que recibe por el constructor (inyección de dependencias, módulo 10). Incluso la fecha la obtiene de un Clock inyectado, para que las pruebas puedan fijarla:
public class PedidoService {
private final GestorTransacciones tx;
private final ClienteDao clienteDao;
private final ProductoDao productoDao;
private final PedidoDao pedidoDao;
private final Clock reloj;
public PedidoService(GestorTransacciones tx, ClienteDao clienteDao, ProductoDao productoDao,
PedidoDao pedidoDao, Clock reloj) {
this.tx = tx;
this.clienteDao = clienteDao;
this.productoDao = productoDao;
this.pedidoDao = pedidoDao;
this.reloj = reloj;
}
/** Alta de un pedido: cabecera + líneas + actualización de stock, todo o nada. */
public Pedido crearPedido(NuevoPedidoDto datos) {
return tx.enTransaccion(() -> {
clienteDao.findById(datos.clienteId())
.orElseThrow(() -> new ReglaNegocioException("No existe el cliente " + datos.clienteId()));
List<LineaPedido> lineas = new ArrayList<>();
int numLinea = 1;
for (NuevoPedidoDto.LineaDto l : datos.lineas()) {
// FOR UPDATE: nadie más puede tocar el stock de este producto hasta el COMMIT
Producto p = productoDao.findByIdParaActualizar(l.productoId())
.orElseThrow(() -> new ReglaNegocioException("No existe el producto " + l.productoId()));
if (p.descatalogado()) {
throw new ReglaNegocioException("El producto " + p.nombre() + " está descatalogado");
}
if (p.stock() < l.cantidad()) {
throw new ReglaNegocioException("Stock insuficiente de " + p.nombre()
+ ": pedidas " + l.cantidad() + ", disponibles " + p.stock());
}
productoDao.modificarStock(p.id(), -l.cantidad());
// El precio se copia en la línea: si mañana cambia, el pedido no se altera
lineas.add(new LineaPedido(numLinea++, p.id(), l.cantidad(), p.precio(), BigDecimal.ZERO));
}
Pedido nuevo = new Pedido(null, datos.clienteId(), datos.empleadoId(),
LocalDate.now(reloj), null, EstadoPedido.PENDIENTE, lineas);
return pedidoDao.insertar(nuevo);
});
}
/** Cancela un pedido PENDIENTE y devuelve sus unidades al stock. */
public void cancelarPedido(int pedidoId) {
tx.enTransaccion(() -> {
Pedido p = pedidoDao.findById(pedidoId)
.orElseThrow(() -> new ReglaNegocioException("No existe el pedido " + pedidoId));
if (p.estado() != EstadoPedido.PENDIENTE) {
throw new ReglaNegocioException("Solo se pueden cancelar pedidos PENDIENTES (el "
+ pedidoId + " está " + p.estado() + ")");
}
for (LineaPedido l : p.lineas()) {
productoDao.modificarStock(l.productoId(), l.cantidad());
}
pedidoDao.actualizarEstado(pedidoId, EstadoPedido.CANCELADO);
});
}
public Optional<Pedido> buscarPedido(int id) {
return pedidoDao.findById(id);
}
// ... pedidosDeCliente, productosDisponibles y buscarProducto delegan en los DAO
}
Figura 8. Secuencia del alta de un pedido: todas las operaciones van en una única transacción.
Las reglas que protege crearPedido:
- El cliente y los productos existen y los productos no están descatalogados.
- Hay stock suficiente de cada producto, comprobado con la fila bloqueada (
FOR UPDATE): dos pedidos simultáneos no pueden vender la misma última unidad. - El precio se copia en la línea: si mañana cambia el precio del producto, el pedido de hoy no varía.
- Si cualquier comprobación falla, la
ReglaNegocioExceptionprovoca elrollback()y se deshacen también los descuentos de stock ya hechos.
10. Pruebas del DAO y del servicio
10.1 H2 en memoria con los scripts de EMPRESA
H2 es una base de datos Java que puede vivir en memoria y arranca en milisegundos. Con MODE=MySQL entiende la sintaxis de MySQL, así que podemos cargar los mismos scripts del curso (copiados en src/test/resources/sql) en una base nueva para cada prueba:
public static DataSource nuevaH2() {
JdbcDataSource ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:empresa" + CONTADOR.incrementAndGet()
+ ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
ds.setUser("sa");
cargarEmpresa(ds);
return ds;
}
// ejecutarScript() lee el fichero, quita los comentarios "--" y ejecuta cada sentencia separada por ';'
⚠️ Importante: H2 en modo MySQL es muy compatible, pero no es MySQL: hay diferencias en funciones, tipos y detalles de cálculo (en el módulo 11 viste un ejemplo con una división). Las pruebas con H2 son rápidas para el día a día; la confirmación definitiva la dan las pruebas contra el mismo motor que en producción (apartado 10.3).
10.2 Pruebas con JUnit 6 y AssertJ
Una prueba parametrizada (módulo 11) comprueba que las dos implementaciones del DAO de departamentos cumplen el mismo contrato:
class DepartamentoDaoTest {
static Stream<Arguments> implementaciones() {
Function<DataSource, DepartamentoDao> clasico = DepartamentoDaoClasico::new;
Function<DataSource, DepartamentoDao> plantilla = ds -> new JdbcDepartamentoDao(new JdbcPlantilla(ds));
return Stream.of(argumentSet("clásico", clasico), argumentSet("con plantilla", plantilla));
}
@ParameterizedTest
@MethodSource("implementaciones")
void crudCompleto(Function<DataSource, DepartamentoDao> fabrica) {
DepartamentoDao dao = fabrica.apply(BaseDatosPrueba.nuevaH2());
Departamento nuevo = dao.save(new Departamento(null, "CALIDAD", "Málaga", null));
assertThat(nuevo.id()).isNotNull();
assertThat(dao.count()).isEqualTo(8);
dao.save(new Departamento(nuevo.id(), "CALIDAD", "Málaga", new BigDecimal("95000.00")));
assertThat(dao.findByNombre("CALIDAD")).get()
.extracting(Departamento::presupuesto).isEqualTo(new BigDecimal("95000.00"));
assertThat(dao.deleteById(nuevo.id())).isTrue();
assertThat(dao.deleteById(nuevo.id())).isFalse();
assertThat(dao.findAll()).hasSize(7).extracting(Departamento::id).startsWith(10, 20);
}
@ParameterizedTest
@MethodSource("implementaciones")
void nombreDuplicadoLanzaIntegridadDatos(Function<DataSource, DepartamentoDao> fabrica) {
DepartamentoDao dao = fabrica.apply(BaseDatosPrueba.nuevaH2());
assertThatThrownBy(() -> dao.save(new Departamento(null, "VENTAS", "Lugo", null)))
.isInstanceOf(IntegridadDatosException.class);
}
}
Y las pruebas del servicio verifican las reglas de negocio y, sobre todo, que el rollback deja la base de datos intacta:
void crearPedidoGuardaLineasYDescuentaStock() {
Pedido p = servicio.crearPedido(new NuevoPedidoDto(1003, 111,
List.of(new LineaDto(1, 2), new LineaDto(6, 3))));
assertThat(p.id()).isNotNull();
assertThat(p.fechaPedido()).isEqualTo(LocalDate.of(2026, 10, 1));
assertThat(p.total()).isEqualByComparingTo("1857.70"); // 2 x 899 + 3 x 19.90
assertThat(stock(1)).isEqualTo(23);
assertThat(stock(6)).isEqualTo(197);
assertThat(servicio.buscarPedido(p.id())).get()
.satisfies(leido -> {
assertThat(leido.estado()).isEqualTo(EstadoPedido.PENDIENTE);
assertThat(leido.lineas()).hasSize(2);
});
}
@Test
void sinStockSuficienteNoSeGuardaNada() {
long antes = numPedidos();
// La 1.ª línea es válida y llega a descontar stock; la 2.ª falla -> ROLLBACK de todo
assertThatThrownBy(() -> servicio.crearPedido(new NuevoPedidoDto(1003, 111,
List.of(new LineaDto(1, 2), new LineaDto(5, 50)))))
.isInstanceOf(ReglaNegocioException.class)
.hasMessageContaining("Stock insuficiente de Torre Workstation");
assertThat(numPedidos()).isEqualTo(antes);
assertThat(stock(1)).isEqualTo(25); // ¡el descuento se deshizo!
}
$ mvn test
...
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running PedidoService (integración con H2)
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.169 s -- in PedidoService (integración con H2)
[INFO] Running es.curso.empresa.ejercicios.EnvioPedidosServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.222 s -- in es.curso.empresa.ejercicios.EnvioPedidosServiceTest
[INFO] Running es.curso.empresa.ejercicios.JdbcClienteDaoCompletoTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.289 s -- in es.curso.empresa.ejercicios.JdbcClienteDaoCompletoTest
[INFO] Running Contrato de DepartamentoDao
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.221 s -- in Contrato de DepartamentoDao
[INFO] Running JdbcProductoDao contra H2 con los datos de EMPRESA
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.827 s -- in JdbcProductoDao contra H2 con los datos de EMPRESA
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 21, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
10.3 Testcontainers: el mismo servicio contra un MySQL real
Testcontainers (módulo 11) arranca un contenedor Docker de MySQL 8.4 para la prueba y lo destruye al terminar. Cargamos en él los scripts de EMPRESA con la misma utilidad y ejecutamos el servicio de verdad:
@Tag("integracion")
@Testcontainers
class PedidoServiceMySqlIT {
@Container
static MySQLContainer mysql = new MySQLContainer("mysql:8.4")
.withDatabaseName("empresa")
.withUsername("curso")
.withPassword("curso")
.withStartupTimeoutSeconds(600); // margen para equipos lentos
static HikariDataSource ds;
static PedidoService servicio;
static JdbcProductoDao productos;
@BeforeAll
static void arrancar() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(mysql.getJdbcUrl());
config.setUsername(mysql.getUsername());
config.setPassword(mysql.getPassword());
config.setMaximumPoolSize(3);
ds = new HikariDataSource(config);
BaseDatosPrueba.cargarEmpresa(ds); // los mismos scripts de ../recursos
JdbcPlantilla jdbc = new JdbcPlantilla(ds);
productos = new JdbcProductoDao(jdbc);
servicio = new PedidoService(new GestorTransacciones(ds), new JdbcClienteDao(jdbc),
productos, new JdbcPedidoDao(jdbc), Clock.systemDefaultZone());
}
@AfterAll
static void cerrar() {
ds.close();
}
@Test
void altaDePedidoEnMySqlReal() {
Pedido p = servicio.crearPedido(new NuevoPedidoDto(1012, 110, List.of(new LineaDto(9, 2))));
assertThat(p.id()).isEqualTo(5025); // siguiente valor del AUTO_INCREMENT
assertThat(productos.findById(9)).get().extracting(Producto::stock).isEqualTo(33);
}
}
El nombre acaba en IT (integration test): por convenio, maven-surefire-plugin no la ejecuta con mvn test (por eso no aparecía arriba), sino maven-failsafe-plugin en la fase verify, o tú a mano con -Dtest:
$ mvn test -Dtest=PedidoServiceMySqlIT
...
[INFO] Running es.curso.empresa.servicio.PedidoServiceMySqlIT
INFO DockerClientFactory - Testcontainers version: 2.0.5
INFO DockerClientProviderStrategy - Found Docker environment with local Unix socket (unix:///var/run/docker.sock)
INFO 4 - Creating container for image: mysql:8.4
INFO 4 - Waiting for database connection to become available at jdbc:mysql://host.docker.internal:60691/empresa using query 'SELECT 1'
INFO 4 - Container mysql:8.4 started in PT1M35.749143582S
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 139.1 s -- in es.curso.empresa.servicio.PedidoServiceMySqlIT
[INFO] BUILD SUCCESS
💡 Consejo:
withStartupTimeoutSeconds(600)amplía el plazo de arranque del contenedor: en un equipo cargado, inicializar MySQL puede superar los 2 minutos que se esperan por defecto. Si Maven se ejecuta dentro de otro contenedor Docker, hay que montarle/var/run/docker.socky definirTESTCONTAINERS_HOST_OVERRIDE=host.docker.internalpara que llegue a los puertos del contenedor de MySQL.
11. La aplicación de consola completa
La capa de presentación es un menú de consola que solo habla con el servicio. El método main es la raíz de composición: el único lugar donde se crean las piezas y se conectan entre sí. Es lo que hará el contenedor de Spring por ti a partir del módulo 17:
public static void main(String[] args) {
// "Raíz de composición": aquí se crean y conectan todas las piezas (lo que hará Spring)
try (HikariDataSource ds = FuenteDatos.crear()) {
JdbcPlantilla jdbc = new JdbcPlantilla(ds);
PedidoService servicio = new PedidoService(new GestorTransacciones(ds),
new JdbcClienteDao(jdbc), new JdbcProductoDao(jdbc), new JdbcPedidoDao(jdbc),
Clock.systemDefaultZone());
new AppPedidos(servicio).ejecutar();
}
}
El resto de la clase son métodos cortos que leen datos, llaman al servicio y muestran el resultado. Por ejemplo, el alta de un pedido y el bucle del menú, que captura las excepciones de negocio y de datos para que un error no termine el programa:
private void nuevoPedido() {
int cliente = leerEntero("Id del cliente: ");
String emp = leer("Id del empleado (vacío = pedido online): ");
Integer empleado = (emp == null || emp.isEmpty()) ? null : Integer.valueOf(emp);
List<NuevoPedidoDto.LineaDto> lineas = new ArrayList<>();
while (true) {
String linea = leer("Producto y cantidad (p. ej. '6 2'; vacío para terminar): ");
if (linea == null || linea.isEmpty()) break;
String[] partes = linea.split("\\s+");
lineas.add(new NuevoPedidoDto.LineaDto(Integer.parseInt(partes[0]), Integer.parseInt(partes[1])));
}
Pedido pedido = servicio.crearPedido(new NuevoPedidoDto(cliente, empleado, lineas));
System.out.println("Pedido " + pedido.id() + " creado: " + pedido.lineas().size()
+ " línea(s), total " + pedido.total() + " €");
}
try {
switch (opcion) {
case "1" -> listarProductos();
case "2" -> pedidosDeCliente();
case "3" -> nuevoPedido();
case "4" -> detallePedido();
case "5" -> cancelarPedido();
default -> System.out.println("Opción no válida");
}
} catch (ReglaNegocioException | IllegalArgumentException e) {
System.out.println("No se pudo completar: " + e.getMessage());
} catch (DataAccessException e) {
System.out.println("Error de base de datos: " + e.getMessage());
}
Se ejecuta con mvn -q compile exec:java (el pom.xml indica la clase principal). Esta es una sesión real: listar productos, consultar los pedidos del cliente 1003, crear un pedido con dos líneas, ver su detalle, intentar un pedido sin stock suficiente, cancelar el pedido creado e intentar cancelar uno ya entregado. Cuando la entrada no viene de un terminal, la aplicación repite lo leído para que la transcripción se entienda:
INFO HikariDataSource - PoolEmpresa - Starting...
INFO HikariPool - PoolEmpresa - Added connection com.mysql.cj.jdbc.ConnectionImpl@79da8dc5
INFO HikariDataSource - PoolEmpresa - Start completed.
=== GESTIÓN DE PEDIDOS - EMPRESA ===
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 1
1 Portátil UltraBook 14 899.00 € stock 25
2 Portátil Gaming 16 1499.00 € stock 10
3 Portátil Básico 15 499.00 € stock 40
4 Torre Oficina i5 649.00 € stock 15
5 Torre Workstation 1899.00 € stock 4
6 Ratón inalámbrico 19.90 € stock 200
7 Teclado mecánico 79.90 € stock 60
9 Monitor 24 FHD 139.00 € stock 35
10 Monitor 27 4K 329.00 € stock 12
11 Smartphone Gama Media 299.00 € stock 50
12 Smartphone Gama Alta 999.00 € stock 8
13 Auriculares Bluetooth 59.90 € stock 80
15 Silla Ergonómica 249.00 € stock 20
16 Mesa Elevable 399.00 € stock 6
17 Webcam HD 45.00 € stock 30
18 Impresora Láser 189.00 € stock 9
19 Disco SSD 1TB 89.90 € stock 70
20 Tablet 10 279.00 € stock 14
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 2
Id del cliente: 1003
5003 2025-02-03 ENTREGADO 2 línea(s) 558.90 €
5015 2025-11-27 ENTREGADO 1 línea(s) 279.00 €
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 3
Id del cliente: 1003
Id del empleado (vacío = pedido online): 111
Producto y cantidad (p. ej. '6 2'; vacío para terminar): 1 2
Producto y cantidad (p. ej. '6 2'; vacío para terminar): 6 3
Producto y cantidad (p. ej. '6 2'; vacío para terminar):
Pedido 5025 creado: 2 línea(s), total 1857.70 €
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 4
Id del pedido: 5025
Pedido 5025 del cliente 1003, 2026-10-04, PENDIENTE
1. Portátil UltraBook 14 2 x 899.00 -0% = 1798.00 €
2. Ratón inalámbrico 3 x 19.90 -0% = 59.70 €
TOTAL: 1857.70 €
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 3
Id del cliente: 1003
Id del empleado (vacío = pedido online):
Producto y cantidad (p. ej. '6 2'; vacío para terminar): 5 50
Producto y cantidad (p. ej. '6 2'; vacío para terminar):
No se pudo completar: Stock insuficiente de Torre Workstation: pedidas 50, disponibles 4
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 5
Id del pedido a cancelar: 5025
Pedido 5025 cancelado y stock repuesto
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 5
Id del pedido a cancelar: 5001
No se pudo completar: Solo se pueden cancelar pedidos PENDIENTES (el 5001 está ENTREGADO)
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 2
Id del cliente: 1003
5003 2025-02-03 ENTREGADO 2 línea(s) 558.90 €
5015 2025-11-27 ENTREGADO 1 línea(s) 279.00 €
5025 2026-10-04 CANCELADO 2 línea(s) 1857.70 €
1. Productos disponibles 2. Pedidos de un cliente 3. Nuevo pedido
4. Detalle de un pedido 5. Cancelar un pedido 0. Salir
Opción: 0
¡Hasta pronto!
INFO HikariDataSource - PoolEmpresa - Shutdown initiated...
INFO HikariDataSource - PoolEmpresa - Shutdown completed.
Observa cómo el pedido sin stock no deja rastro (el último listado no lo muestra: el rollback deshizo todo) y cómo, tras cancelar el 5025, su estado pasa a CANCELADO; el servicio ha devuelto sus 2 portátiles y 3 ratones al stock en la misma transacción. Los productos 8 y 14 no aparecen en el primer listado porque findDisponibles() excluye los agotados y los descatalogados.
12. JDBC frente a un ORM
Figura 9. JDBC y un ORM: el ORM genera el SQL y el mapeo, pero por debajo sigue usando JDBC.
Lo que hemos construido en este módulo (mapear filas a objetos, guardar objetos en filas, gestionar transacciones) es justo lo que automatiza un ORM (Object-Relational Mapping) como Hibernate, la implementación de referencia de Jakarta Persistence (JPA). Con JPA anotas la clase Producto con @Entity y sus campos con @Id, @Column, @ManyToOne…, y un EntityManager genera el SQL de find, persist o las consultas JPQL.
| Aspecto | JDBC (con DAO y plantilla) | ORM (JPA / Hibernate) |
|---|---|---|
| SQL | Lo escribes tú: control total | Lo genera el ORM (puedes escribirlo si quieres) |
| Mapeo fila ↔ objeto | RowMapper manual | Automático a partir de anotaciones |
| Relaciones (pedido → líneas → producto) | Consultas y ensamblado a mano | Navegación entre objetos (pedido.getLineas()) con carga perezosa |
| Cambios | UPDATE explícito | Detección automática de cambios al confirmar |
| Caché | No | Caché de primer nivel (y opcional de segundo) |
| Curva de aprendizaje | Baja: es SQL | Alta: ciclo de vida de entidades, proxies, N+1… |
| Rendimiento | Predecible | Muy bueno si sabes qué SQL se genera; desastroso si no |
| Cuándo encaja | Informes, procesos masivos, SQL complejo, equipos que dominan SQL | Aplicaciones con modelo de dominio rico y muchas operaciones CRUD |
💡 Consejo: No es una elección excluyente. Muchas aplicaciones usan JPA para el día a día y JDBC (o
JdbcTemplate) para los informes y las cargas masivas. Y saber JDBC es imprescindible para entender los problemas típicos de un ORM: cuando Hibernate lance 101 consultas donde esperabas una (problema N+1, módulo 16), sabrás leer el SQL que genera.
13. Buenas prácticas y errores comunes
| Error común | Consecuencia | Buena práctica |
|---|---|---|
Olvidar rollback() en el catch | La conexión queda con una transacción abierta y bloqueos retenidos | Patrón setAutoCommit(false) / commit / rollback / finally |
Devolver al pool una conexión con autoCommit=false | La siguiente operación hereda una transacción ajena | Restaurar setAutoCommit(true) en el finally |
| Transacciones largas (esperar al usuario con la transacción abierta) | Bloqueos y esperas para todos | Leer los datos, pedirlos al usuario y abrir la transacción solo para escribir |
Comprobar stock con un SELECT normal y luego restar | Dos ventas simultáneas de la última unidad | SELECT ... FOR UPDATE o un UPDATE ... WHERE stock >= ? comprobando las filas afectadas |
| Insertar miles de filas una a una con autocommit | Procesos de minutos que podrían tardar segundos | Lotes dentro de una transacción y rewriteBatchedStatements=true |
Crear un HikariDataSource por operación | Peor que no tener pool | Un único pool para toda la aplicación |
| Pool enorme “por si acaso” | La base de datos se satura | Pool pequeño y conexiones devueltas cuanto antes |
SQLException propagada hasta la interfaz | Capas acopladas a JDBC | Traducir a DataAccessException en el DAO |
| SQL en la clase del menú o del controlador | Código imposible de probar y reutilizar | DAO para el acceso a datos y servicio para las reglas |
| Probar solo con H2 | Diferencias con MySQL que aparecen en producción | H2 para rapidez y Testcontainers para la verificación final |
14. Ejercicios
Los ejercicios usan el proyecto del módulo y la base de datos EMPRESA. Los que modifican datos los dejan como estaban o trabajan en modo simulación; si algo se tuerce, recarga los scripts (apartado 2.2 del módulo 12).
Ejercicio 1: Fusión de departamentos
Escribe fusionar(Connection con, int origen, int destino, boolean simular), que mueva los empleados y los proyectos del departamento origen al destino y borre origen, todo en una transacción. Si simular es true, debe mostrar lo que haría y deshacerlo con rollback(). Pruébalo fusionando CONTABILIDAD (40) con DIRECCIÓN (10) en modo simulación y con un origen inexistente.
Ver solución
/** Mueve empleados y proyectos de 'origen' a 'destino' y borra 'origen'. Todo o nada. */
static void fusionar(Connection con, int origen, int destino, boolean simular) throws SQLException {
con.setAutoCommit(false);
try (PreparedStatement emp = con.prepareStatement("UPDATE empleados SET dept_id = ? WHERE dept_id = ?");
PreparedStatement pry = con.prepareStatement("UPDATE proyectos SET dept_id = ? WHERE dept_id = ?");
PreparedStatement del = con.prepareStatement("DELETE FROM departamentos WHERE dept_id = ?")) {
emp.setInt(1, destino);
emp.setInt(2, origen);
pry.setInt(1, destino);
pry.setInt(2, origen);
del.setInt(1, origen);
int e = emp.executeUpdate();
int p = pry.executeUpdate();
int d = del.executeUpdate();
if (d != 1) {
throw new SQLException("El departamento " + origen + " no existe");
}
System.out.printf("Movidos %d empleados y %d proyectos; borrado el departamento %d%n", e, p, origen);
if (simular) {
con.rollback();
System.out.println("Simulación: ROLLBACK, no se ha cambiado nada");
} else {
con.commit();
System.out.println("COMMIT");
}
} catch (SQLException ex) {
con.rollback();
throw ex;
} finally {
con.setAutoCommit(true);
}
}
departamentos=7, empleados en 40=3, empleados en 10=1
Movidos 3 empleados y 0 proyectos; borrado el departamento 40
Simulación: ROLLBACK, no se ha cambiado nada
departamentos=7, empleados en 40=3, empleados en 10=1
Error: El departamento 99 no existe -> ROLLBACK
departamentos=7, empleados en 40=3, empleados en 10=1
El main muestra los contadores antes y después de cada intento. En la simulación las tres sentencias se ejecutan de verdad (por eso sabemos cuántas filas movería) y el rollback() las deshace. Con el departamento 99 los dos primeros UPDATE afectan a 0 filas, el DELETE también, y la excepción provoca el rollback.
Ejercicio 2: Importación de productos por lotes
Escribe un importador que reciba un texto CSV con líneas nombre;categoria_id;precio;stock, inserte todos los productos con un lote (JdbcPlantilla.batchUpdate) dentro de una transacción de GestorTransacciones y devuelva cuántos ha insertado. Si cualquier línea falla, no debe quedar ninguna. Pruébalo con un CSV correcto y con otro que contenga un precio negativo.
Ver solución
/** Formato: nombre;categoria_id;precio;stock. Importa todas las líneas o ninguna. */
int importar(String csv) {
List<Object[]> filas = new ArrayList<>();
for (String linea : csv.strip().split("\n")) {
String[] c = linea.split(";");
filas.add(new Object[]{c[0].trim(), Integer.parseInt(c[1].trim()),
new BigDecimal(c[2].trim()), Integer.parseInt(c[3].trim()), LocalDate.of(2026, 10, 1)});
}
return tx.enTransaccion(() -> jdbc.batchUpdate("""
INSERT INTO productos (nombre, categoria_id, precio, stock, fecha_alta)
VALUES (?, ?, ?, ?, ?)""", filas).length);
}
INFO HikariDataSource - PoolEmpresa - Starting...
INFO HikariPool - PoolEmpresa - Added connection com.mysql.cj.jdbc.ConnectionImpl@79da8dc5
INFO HikariDataSource - PoolEmpresa - Start completed.
Productos antes: 20
Importados: 3
Productos ahora: 23
Importación rechazada: Check constraint 'ck_pro_precio' is violated.
Productos tras el fallo: 23
INFO HikariDataSource - PoolEmpresa - Shutdown initiated...
INFO HikariDataSource - PoolEmpresa - Shutdown completed.
batchUpdate envía las líneas juntas por la conexión de la transacción. El precio negativo viola ck_pro_precio, la DataAccessException provoca el rollback() y tampoco se guarda la funda, aunque su línea fuera correcta. El programa termina borrando los productos importados.
Ejercicio 3: DAO completo de clientes con pruebas
Amplía el acceso a clientes con findByProvincia(String) (ordenado por nombre) y save(Cliente) (inserta si no tiene id; si lo tiene, actualiza) usando JdbcPlantilla, y escribe sus pruebas con H2: búsqueda por provincia, alta seguida de modificación, y NIF duplicado.
Ver solución
public List<Cliente> findByProvincia(String provincia) {
return jdbc.query(SELECT + " WHERE provincia = ? ORDER BY nombre", MAPPER, provincia);
}
public Cliente save(Cliente c) {
if (c.id() == null) {
int id = jdbc.insertarConClave("""
INSERT INTO clientes (nombre, nif, ciudad, provincia, limite_credito, comercial_id)
VALUES (?, ?, ?, ?, ?, ?)""",
c.nombre(), c.nif(), c.ciudad(), c.provincia(), c.limiteCredito(), c.comercialId());
return new Cliente(id, c.nombre(), c.nif(), c.ciudad(), c.provincia(),
c.limiteCredito(), c.comercialId());
}
jdbc.update("""
UPDATE clientes SET nombre = ?, nif = ?, ciudad = ?, provincia = ?,
limite_credito = ?, comercial_id = ?
WHERE cliente_id = ?""",
c.nombre(), c.nif(), c.ciudad(), c.provincia(), c.limiteCredito(), c.comercialId(), c.id());
return c;
}
void altaYModificacion() {
Cliente nuevo = dao.save(new Cliente(null, "Librería El Quijote", "B11223344", "Toledo",
"Toledo", new BigDecimal("3500.00"), null));
assertThat(nuevo.id()).isNotNull();
dao.save(new Cliente(nuevo.id(), nuevo.nombre(), nuevo.nif(), nuevo.ciudad(), nuevo.provincia(),
new BigDecimal("5000.00"), 112));
assertThat(dao.findById(nuevo.id())).get().satisfies(c -> {
assertThat(c.limiteCredito()).isEqualByComparingTo("5000");
assertThat(c.comercialId()).isEqualTo(112);
});
}
[INFO] Running es.curso.empresa.ejercicios.JdbcClienteDaoCompletoTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.289 s -- in es.curso.empresa.ejercicios.JdbcClienteDaoCompletoTest
Cada prueba recibe una base H2 nueva, así que el cliente insertado en una no interfiere en las demás. El NIF duplicado (B12345678, de Talleres Martín) llega como IntegridadDatosException gracias a la traducción por SQLState.
Ejercicio 4: Agotar el pool
Configura en código un HikariDataSource con solo 2 conexiones y connectionTimeout de 2 segundos, y lanza 5 tareas concurrentes que ocupen una conexión durante 4 segundos (SELECT SLEEP(4)). ¿Qué les pasa a las tareas que no consiguen conexión?
Ver solución
public static void main(String[] args) throws Exception {
HikariConfig config = new HikariConfig("/hikari.properties"); // URL y credenciales
config.setPoolName("PoolPequeño");
config.setMaximumPoolSize(2);
config.setMinimumIdle(2);
config.setConnectionTimeout(2000); // esperar como mucho 2 s por una conexión
try (HikariDataSource ds = new HikariDataSource(config);
ExecutorService ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> tareas = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
int n = i;
tareas.add(ejecutor.submit(() -> {
try (Connection con = ds.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT SLEEP(4)")) {
ps.executeQuery().close(); // ocupa la conexión 4 s
return "tarea " + n + ": OK";
} catch (SQLException e) {
return "tarea " + n + ": " + e.getClass().getSimpleName() + " - " + e.getMessage();
}
}));
}
for (Future<String> f : tareas) {
System.out.println(f.get());
}
}
}
INFO HikariDataSource - PoolPequeño - Starting...
INFO HikariPool - PoolPequeño - Added connection com.mysql.cj.jdbc.ConnectionImpl@67080771
INFO HikariDataSource - PoolPequeño - Start completed.
tarea 1: OK
tarea 2: OK
tarea 3: SQLTransientConnectionException - PoolPequeño - Connection is not available, request timed out after 2008ms (total=2, active=2, idle=0, waiting=2)
tarea 4: SQLTransientConnectionException - PoolPequeño - Connection is not available, request timed out after 2001ms (total=2, active=2, idle=0, waiting=1)
tarea 5: SQLTransientConnectionException - PoolPequeño - Connection is not available, request timed out after 2007ms (total=2, active=2, idle=0, waiting=0)
INFO HikariDataSource - PoolPequeño - Shutdown initiated...
INFO HikariDataSource - PoolPequeño - Shutdown completed.
Dos tareas consiguen conexión; las otras tres esperan 2 s y reciben SQLTransientConnectionException. Es lo que ocurre en producción cuando el código olvida cerrar conexiones o mantiene transacciones demasiado largas. HikariConfig admite cargar el fichero de propiedades y sobrescribir después lo que quieras.
Ejercicio 5: Servicio de envío de pedidos
Crea un servicio con un método enviar(int pedidoId) que, dentro de una transacción, compruebe que el pedido existe y está PENDIENTE, y lo marque como ENVIADO con la fecha de hoy (obtenida de un Clock). Escribe las pruebas con H2 y un reloj fijo.
Ver solución
public LocalDate enviar(int pedidoId) {
return tx.enTransaccion(() -> {
Pedido p = pedidoDao.findById(pedidoId)
.orElseThrow(() -> new ReglaNegocioException("No existe el pedido " + pedidoId));
if (p.estado() != EstadoPedido.PENDIENTE) {
throw new ReglaNegocioException("El pedido " + pedidoId + " ya está " + p.estado());
}
LocalDate hoy = LocalDate.now(reloj);
jdbc.update("UPDATE pedidos SET estado = ?, fecha_envio = ? WHERE pedido_id = ?",
EstadoPedido.ENVIADO, hoy, pedidoId);
return hoy;
});
}
void enviaUnPedidoPendiente() {
assertThat(servicio.enviar(5022)).isEqualTo(LocalDate.of(2026, 10, 2));
Pedido p = pedidos.findById(5022).orElseThrow();
assertThat(p.estado()).isEqualTo(EstadoPedido.ENVIADO);
assertThat(p.fechaEnvio()).isEqualTo(LocalDate.of(2026, 10, 2));
}
@Test
void noReenviaUnPedidoEntregado() {
assertThatThrownBy(() -> servicio.enviar(5001))
.isInstanceOf(ReglaNegocioException.class)
.hasMessage("El pedido 5001 ya está ENTREGADO");
}
[INFO] Running es.curso.empresa.ejercicios.EnvioPedidosServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.222 s -- in es.curso.empresa.ejercicios.EnvioPedidosServiceTest
Gracias al Clock inyectado (Clock.fixed(Instant.parse("2026-10-02T09:00:00Z"), ZoneId.of("Europe/Madrid"))), la prueba sabe qué fecha debe aparecer en fecha_envio. El enum ENVIADO se convierte en texto en JdbcPlantilla.asignar.
Ejercicio 6: Informe de ventas por categoría
Escribe una consulta con JdbcPlantilla que devuelva, para un año, las ventas agrupadas por la categoría padre de la de cada producto (Portátiles cuenta dentro de Informática; Ratones y teclados, dentro de Periféricos): número de pedidos, unidades e importe, excluyendo los pedidos cancelados. Guarda cada fila en un record DTO y muestra el porcentaje de cada categoría sobre el total de 2025 y de 2026.
Ver solución
record VentasCategoria(String categoria, int pedidos, int unidades, BigDecimal importe) { }
static List<VentasCategoria> ventasPorCategoria(JdbcPlantilla jdbc, int anio) {
return jdbc.query("""
SELECT COALESCE(padre.nombre, c.nombre) AS categoria,
COUNT(DISTINCT p.pedido_id) AS pedidos,
SUM(l.cantidad) AS unidades,
SUM(l.cantidad * l.precio_unitario * (1 - l.descuento / 100)) AS importe
FROM lineas_pedido l
JOIN pedidos p ON p.pedido_id = l.pedido_id
JOIN productos pr ON pr.producto_id = l.producto_id
JOIN categorias c ON c.categoria_id = pr.categoria_id
LEFT JOIN categorias padre ON padre.categoria_id = c.categoria_padre_id
WHERE p.estado <> 'CANCELADO' AND YEAR(p.fecha_pedido) = ?
GROUP BY COALESCE(padre.nombre, c.nombre)
ORDER BY importe DESC""",
(rs, n) -> new VentasCategoria(rs.getString("categoria"), rs.getInt("pedidos"),
rs.getInt("unidades"), rs.getBigDecimal("importe").setScale(2, RoundingMode.HALF_UP)),
anio);
}
INFO HikariDataSource - PoolEmpresa - Starting...
INFO HikariPool - PoolEmpresa - Added connection com.mysql.cj.jdbc.ConnectionImpl@79da8dc5
INFO HikariDataSource - PoolEmpresa - Start completed.
Ventas de 2025 (total 40912.67 €)
Informática 9 pedidos 54 uds 26387.09 € 64.5 %
Periféricos 9 pedidos 87 uds 8944.65 € 21.9 %
Electrónica 5 pedidos 18 uds 3936.93 € 9.6 %
Oficina 2 pedidos 6 uds 1644.00 € 4.0 %
Ventas de 2026 (total 20704.79 €)
Informática 5 pedidos 27 uds 14583.10 € 70.4 %
Electrónica 2 pedidos 8 uds 2504.05 € 12.1 %
Periféricos 2 pedidos 18 uds 2321.64 € 11.2 %
Oficina 1 pedidos 4 uds 1296.00 € 6.3 %
INFO HikariDataSource - PoolEmpresa - Shutdown initiated...
INFO HikariDataSource - PoolEmpresa - Shutdown completed.
COALESCE(padre.nombre, c.nombre) sube cada producto un nivel (o lo deja donde está si su categoría ya es raíz). Como EMPRESA tiene tres niveles, Periféricos aparece aparte de Informática; para agrupar siempre por la raíz de una jerarquía de profundidad arbitraria usarías una consulta recursiva WITH RECURSIVE. El total y los porcentajes se calculan en Java sobre los DTO.
Ejercicio 7: Transferencia de stock entre productos (propuesto)
Añade al servicio un método que convierta unidades de un producto en otro (por ejemplo, desmontar packs de teclado y ratón en ratones sueltos): resta del origen y suma al destino en una transacción, con bloqueo FOR UPDATE de los dos productos siempre en el mismo orden (por id) para evitar interbloqueos. Pruébalo con H2.
Ejercicio 8: DAO de proyectos y asignaciones (propuesto)
Crea ProyectoDao con findAll, findByDepartamento y asignarEmpleado(empId, proyectoId, horas, funcion), teniendo en cuenta que la clave de asignaciones es compuesta. Traduce la violación de clave primaria (empleado ya asignado) a un mensaje claro.
Ejercicio 9: Prueba de concurrencia del stock (propuesto)
Escribe una prueba con Testcontainers en la que 10 hilos virtuales intenten comprar a la vez la última unidad de un producto (pon su stock a 1). Comprueba que exactamente un pedido se crea y nueve reciben ReglaNegocioException. Después quita el FOR UPDATE del DAO y observa qué ocurre.
Ejercicio 10: Paginación (propuesto)
Añade a ProductoDao un método findPagina(int pagina, int tamano) con LIMIT ? OFFSET ? que devuelva un record Pagina<T>(List<T> contenido, int pagina, long totalElementos). Úsalo en la aplicación para mostrar los productos de 5 en 5.
Ejercicio 11: Auditoría con lotes (propuesto)
Crea una tabla auditoria(id, fecha, operacion, detalle) y haz que el servicio registre en ella cada pedido creado o cancelado dentro de la misma transacción. Después escribe un proceso que archive en otra tabla, por lotes de 500, las entradas de más de un año y las borre de la original.
Ejercicio 12: Informe en la aplicación (propuesto)
Añade al menú de AppPedidos una opción “Informe de ventas” que use el ejercicio 6 a través de un nuevo InformeService y permita elegir el año. Mantén las capas: la presentación no puede ver SQL ni JdbcPlantilla.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Transacción | con.setAutoCommit(false) → sentencias → commit(); en el catch, rollback(); en el finally, setAutoCommit(true) |
| Punto de guardado | Savepoint sp = con.setSavepoint("nombre") y con.rollback(sp) |
| Aislamiento | con.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); MySQL usa REPEATABLE READ |
| Bloqueo de filas | SELECT ... FOR UPDATE dentro de una transacción |
| Lotes | ps.addBatch() + ps.executeBatch() → int[]; rewriteBatchedStatements=true en MySQL |
| Pool de conexiones | new HikariDataSource(new HikariConfig("/hikari.properties")), uno por aplicación |
DataSource | ds.getConnection(); close() devuelve la conexión al pool |
| DAO | Interfaz Dao<T, ID> (findById, findAll, save, deleteById, count) + implementación JDBC |
| Excepción propia | DataAccessException extends RuntimeException, traducida según el SQLState |
RowMapper<T> | (rs, n) -> new Producto(...): el mapeo como lambda |
| Plantilla JDBC | query, queryForOptional, update, insertarConClave, batchUpdate |
| Transacción entre DAO | Conexión en un ThreadLocal + enTransaccion(() -> ...): la idea de @Transactional |
| Servicio | Reglas de negocio + límites de transacción; recibe DAO por el constructor |
| DTO | Records que transportan datos entre capas (NuevoPedidoDto, PedidoResumenDto) |
| Pruebas | H2 MODE=MySQL con los scripts del curso; Testcontainers con mysql:8.4 |
| Raíz de composición | El main crea y conecta todas las piezas (lo hará Spring) |