¿Recuerdas esos días en los que acceder a una base de datos directamente con JDBC era una odisea de try-catch-finally
con conexiones, PreparedStatements
y ResultSets
manuales? Spring Framework nos rescató hace años con JdbcTemplate
, una abstracción maravillosa que redujo drásticamente el boilerplate. Sin embargo, el panorama de desarrollo evoluciona. Los paradigmas funcionales y reactivos se afianzan, y con ellos, la búsqueda de APIs más fluidas, legibles y concisas. Es aquí donde Spring Boot, en su constante innovación, nos trae una joya en su versión 3.2: JdbcClient
.
Este post no es solo una presentación; es una inmersión profunda con código, donde exploraremos cómo JdbcClient
moderniza el acceso a datos relacionales, ofreciendo un equilibrio perfecto entre la potencia del SQL nativo y la conveniencia de una API fluida. Si eres un desarrollador Spring que busca optimizar la interacción con bases de datos sin la complejidad de un ORM completo, o simplemente quieres estar al día con las últimas capacidades de Spring Boot, has llegado al lugar correcto. Prepárate para descubrir cómo JdbcClient
puede transformar tu manera de escribir código de acceso a datos, haciéndolo más limpio, más rápido de escribir y, francamente, más agradable de leer. ¡Vamos a ello!
¿Qué es `JdbcClient` y por qué debería importarme?

JdbcClient
es la nueva adición de Spring Framework 6.1 (adoptada en Spring Boot 3.2) diseñada para proporcionar una interfaz de programación más moderna y fluida para interactuar con bases de datos relacionales utilizando JDBC. Piensa en él como el sucesor espiritual de JdbcTemplate
, pero con una orientación más hacia el patrón builder y una sintaxis que, en mi opinión, se alinea mucho mejor con los estilos de programación contemporáneos, especialmente aquellos influenciados por las APIs funcionales.
Mientras que JdbcTemplate
ha sido y sigue siendo una herramienta robusta, su API a veces podía sentirse un poco verbosa para ciertas operaciones o requería el uso de clases anónimas (o lambdas con la llegada de Java 8) para mapear resultados. JdbcClient
, por otro lado, abraza un diseño más declarativo y encadenado, permitiéndonos construir consultas y actualizaciones de una manera que es casi como escribir una frase en SQL, pero con la seguridad de tipo y la gestión de recursos de Spring.
¿Por qué debería importarte? Porque llena un nicho importante:
- Cuando JPA es demasiado: Para microservicios o aplicaciones donde solo necesitas un CRUD sencillo o consultas SQL muy específicas que no se benefician de toda la infraestructura y el mapeo objeto-relacional de JPA/Hibernate. A veces, la simplicidad de SQL directo es inmejorable.
-
Modernización de
JdbcTemplate
: Si ya utilizasJdbcTemplate
y buscas una mejora en la legibilidad y la concisión del código sin abandonar la filosofía de acceso a datos directo. -
Rendimiento: Al ser una abstracción ligera sobre JDBC,
JdbcClient
ofrece un rendimiento muy cercano al acceso directo a la base de datos, lo cual es crucial en aplicaciones de alta concurrencia o donde el control granular del SQL es una prioridad. - Flexibilidad: Permite ejecutar cualquier consulta SQL, desde las más simples hasta las más complejas, manteniendo el control total sobre la lógica de la base de datos.
Considero que JdbcClient
es una excelente herramienta que reside en un punto dulce entre la potencia bruta de JDBC y la comodidad que ofrecen las abstracciones de Spring. No reemplaza a JPA, sino que complementa el ecosistema de acceso a datos de Spring, ofreciendo una alternativa robusta para escenarios específicos.
Configuración Inicial: Preparando el Terreno
Antes de sumergirnos en el código, necesitamos configurar nuestro proyecto Spring Boot. Asegúrate de estar usando Spring Boot 3.2.x o superior.
Primero, las dependencias. Necesitamos el starter JDBC y, para este tutorial, utilizaremos H2 como base de datos en memoria para simplificar.
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Para la configuración de la base de datos, JdbcClient
se integra automáticamente con cualquier DataSource
configurado por Spring Boot. Para H2, Spring Boot lo autoconfigura, pero podemos especificar algunas propiedades si queremos:
# application.properties
spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.h2.console.enabled=true # Habilitar la consola H2 para verificar datos
spring.sql.init.mode=always # Siempre inicializar la BD al inicio
Para inicializar la base de datos con una tabla y algunos datos de ejemplo, podemos crear un archivo schema.sql
y data.sql
en src/main/resources
:
-- src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS products (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description VARCHAR(500),
price DECIMAL(10, 2) NOT NULL
);
-- src/main/resources/data.sql
INSERT INTO products (name, description, price) VALUES ('Laptop XPS 15', 'Potente laptop con pantalla OLED', 1899.99);
INSERT INTO products (name, description, price) VALUES ('Teclado Mecánico RGB', 'Teclado para gamers con switches Cherry MX', 129.50);
INSERT INTO products (name, description, price) VALUES ('Monitor Curvo 27"', 'Monitor ideal para productividad y juegos', 349.00);
Con esto, nuestra aplicación estará lista para conectar y trabajar con la base de datos. La inyección de JdbcClient
es sencilla; Spring Boot lo registra automáticamente como un bean cuando detecta spring-boot-starter-jdbc
.
Para más detalles sobre la configuración de acceso a datos en Spring Boot, puedes consultar la documentación oficial de Spring Boot Data Access.
Tutorial: CRUD Completo con `JdbcClient`
Vamos a construir un pequeño servicio RESTful para gestionar productos. Necesitaremos un modelo de datos, una interfaz de repositorio y su implementación con JdbcClient
, y un controlador para exponer la API.
1. El Modelo de Datos: `Product`
Usaremos un Java record
para nuestro modelo Product
, lo cual es ideal para objetos inmutables y de datos.
// src/main/java/com/example/demo/product/Product.java
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String name, String description, BigDecimal price) {
// Los records son concisos y perfectos para DTOs o entidades simples
}
2. Interfaz del Repositorio: `ProductRepository`
Definimos una interfaz para nuestras operaciones de CRUD. Esto es una buena práctica para desacoplar la lógica de negocio de la implementación de persistencia.
// src/main/java/com/example/demo/product/ProductRepository.java
package com.example.demo.product;
import java.util.List;
import java.util.Optional;
public interface ProductRepository {
List<Product> findAll();
Optional<Product> findById(Long id);
Product save(Product product); // Para crear y actualizar
void deleteById(Long id);
}
3. Implementación del Repositorio con `JdbcClient`
Aquí es donde brilla JdbcClient
. Inyectaremos JdbcClient
y lo usaremos para implementar cada método del repositorio.
// src/main/java/com/example/demo/product/JdbcClientProductRepository.java
package com.example.demo.product;
import org.springframework.jdbc.core.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import org.springframework.util.Assert;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Repository
public class JdbcClientProductRepository implements ProductRepository {
private final JdbcClient jdbcClient;
// JdbcClient se inyecta automáticamente
public JdbcClientProductRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<Product> findAll() {
return jdbcClient.sql("SELECT id, name, description, price FROM products")
.query(Product.class) // Mapeo directo a la clase Product
.list();
}
@Override
public Optional<Product> findById(Long id) {
return jdbcClient.sql("SELECT id, name, description, price FROM products WHERE id = :id")
.param("id", id) // Uso de parámetros con nombre, mucho más claro
.query(Product.class)
.optional(); // Retorna un Optional para manejo de nulls
}
@Override
public Product save(Product product) {
Assert.notNull(product, "Product must not be null");
if (product.id() == null) {
// Insertar nuevo producto
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcClient.sql("INSERT INTO products (name, description, price) VALUES (:name, :description, :price)")
.param("name", product.name())
.param("description", product.description())
.param("price", product.price())
.update(keyHolder); // Ejecuta la actualización y captura la clave generada
return new Product(keyHolder.getKey().longValue(), product.name(), product.description(), product.price());
} else {
// Actualizar producto existente
int updatedRows = jdbcClient.sql("UPDATE products SET name = :name, description = :description, price = :price WHERE id = :id")
.param("name", product.name())
.param("description", product.description())
.param("price", product.price())
.param("id", product.id())
.update(); // Retorna el número de filas afectadas
Assert.state(updatedRows == 1, "Product with id " + product.id() + " not found for update.");
return product; // Retornamos el producto actualizado (asumiendo éxito)
}
}
@Override
public void deleteById(Long id) {
jdbcClient.sql("DELETE FROM products WHERE id = :id")
.param("id", id)
.update();
}
}
Notas sobre la implementación:
-
Fluent API: Observa cómo
jdbcClient.sql(...).param(...).query(...).list()
o.update()
encadenan las llamadas de forma muy legible. -
Parámetros con nombre: El uso de
:id
,:name
, etc., hace que el SQL sea mucho más claro que los?
deJdbcTemplate
. -
Mapeo automático:
query(Product.class)
intenta mapear directamente las columnas delResultSet
a los campos delrecord
/claseProduct
por nombre. Si los nombres no coinciden, se puede usar unRowMapper
personalizado. -
Generación de claves:
GeneratedKeyHolder
es la forma estándar de obtener IDs generados automáticamente tras una inserción. -
Métodos
single()
,optional()
,list()
: Proporcionan formas convenientes de manejar los resultados de las consultas.optional()
es especialmente útil para evitarNullPointerExceptions
cuando un registro podría no existir.
Para más información sobre JdbcClient
, puedes consultar la documentación oficial de Spring Framework.
4. El Controlador REST: `ProductController`
Finalmente, expondremos las operaciones CRUD a través de una API REST.
// src/main/java/com/example/demo/product/ProductController.java
package com.example.demo.product;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@GetMapping
public List<Product> getAllProducts() {
return productRepository.findAll();
}
@GetMapping("/{id}")
public Product getProductById(@PathVariable Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product createProduct(@RequestBody Product product) {
// En una creación, el ID debería ser nulo para que el repositorio lo genere
if (product.id() != null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ID must be null for new product creation");
}
return productRepository.save(product);
}
@PutMapping("/{id}")
public Product updateProduct(@PathVariable Long id, @RequestBody Product product) {
if (!id.equals(product.id())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ID in path must match ID in body");
}
return productRepository.save(product);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteProduct(@PathVariable Long id) {
productRepository.deleteById(id);
}
}
5. La Aplicación Principal
La clase main
de Spring Boot, como siempre, arranca todo.
// src/main/java/com/example/demo/DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
¡Y eso es todo! Con estas piezas, tienes una aplicación Spring Boot funcionando que utiliza JdbcClient
para todas las operaciones de persistencia. Puedes iniciar la aplicación y probar los endpoints con Postman, Insomnia o curl
.
Consideraciones Adicionales y Buenas Prácticas
Aunque hemos cubierto lo básico, hay algunos aspectos más avanzados y buenas prácticas que vale la pena mencionar:
-
RowMapper Personalizados: Si el mapeo automático de
query(Class)
no es suficiente (por ejemplo, nombres de columna diferentes a los campos, o lógica de mapeo más compleja), puedes pasar unRowMapper
personalizado.jdbcClient.sql("SELECT p_id, p_name FROM products") .query((rs, rowNum) -> new Product(rs.getLong("p_id"), rs.getString("p_name"), null, null)) .list();
-
Transacciones:
JdbcClient
se integra perfectamente con la gestión de transacciones declarativa de Spring. Simplemente usa@Transactional
en tus métodos de servicio o repositorio. Más sobre transacciones en Spring. -
Batch Operations:
JdbcClient
también soporta operaciones por lotes para un rendimiento óptimo al insertar o actualizar múltiples registros.List<Object[]> batchArgs = products.stream() .map(p -> new Object[]{p.name(), p.description(), p.price()}) .toList(); jdbcClient.sql("INSERT INTO products (name, description, price) VALUES (?, ?, ?)") .update(batchArgs);
-
Seguridad: Como siempre al interactuar directamente con SQL, ten cuidado con la inyección SQL.
JdbcClient
protege contra esto automáticamente cuando usas parámetros (:param
o?
). Nunca concatenes directamente valores de entrada del usuario en tus cadenas SQL. Puedes aprender más sobre seguridad en Spring en la documentación de Spring Security. -
Pruebas:
JdbcClient
es muy fácil de probar. Puedes usar bases de datos en memoria como H2 en tus tests y el propioJdbcClient
puede ser fácilmente mockeado o, mejor aún, integrado en tests de integración con@SpringBootTest
. Un excelente recurso para esto es el blog de Baeldung sobre pruebas en Spring Boot.
Conclusión
JdbcClient
es una adición fantástica al arsenal de Spring para el acceso a datos. Ofrece una sintaxis moderna y fluida que reduce el boilerplate, mejora la legibilidad y proporciona un control granular sobre tus sentencias SQL, todo ello sin sacrificar las ventajas de la gestión de recursos y transacciones de Spring. En mi experiencia, esta herramienta es perfecta para escenarios donde JdbcTemplate
se siente un poco anticuado pero JPA es una solución excesivamente compleja o pesada para los requisitos específicos.
Si estás trabajando con Spring Boot 3.2 o posterior, te animo encarecidamente a probar JdbcClient
en tu próximo proyecto o a considerar refactorizar partes de tu código existente que utilizan JdbcTemplate
. Verás cómo puede hacer que tu código de acceso a datos sea más elegante y fácil de mantener. ¡Feliz codificación!