JdbcClient and Spring Data JDBC: When Not to Use JPA

Introduction

This tutorial, the last of the data-access module, looks at the two ways Spring offers to work with a relational database without JPA. We will start with JdbcClient, Spring's fluent API over plain SQL: inserts with generated keys, queries mapped to records, single values, and updates. We will then use Spring Data JDBC, which gives the familiar repository interfaces with a much simpler model than JPA, and see how it saves an aggregate of an order and its lines, reading the SQL it runs. Lastly, we will compare JdbcClient, Spring Data JDBC and JPA, and when to choose each.

Why look beyond JPA?

The previous tutorials showed what JPA and Hibernate do for us, and what they do behind our backs: a persistence context, dirty checking, lazy loading and its N+1 problemClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot, proxies, and SQL that we must switch on logging to see. For many applications that is a good trade. For others - reports, bulk operations, simple services over a well-known schema, or teams who want to see every statement - it is more machinery than they need. Spring has two lighter options -
  • JdbcClient (Spring Framework 6.1 and later): you write the SQL, Spring handles connections, parameters, result mapping and exceptions. It is the modern face of JdbcTemplate, which it uses underneath.
  • Spring Data JDBC: repositories and derived queries like Spring Data JPA, but no persistence context, no lazy loading, and no dirty checking. A save() writes, a find reads, and nothing happens in between.

The example

The project uses spring-boot-starter-data-jdbc and H2, with no JPA on the classpath. For an embedded database, Spring Boot runs schema.sql from the classpath at startup, so the tables are created by plain SQL. In a real application they would come from Flyway or Liquibase migrationsClick to read tutorial: Database Migrations with Flyway and Liquibase in Spring Boot -
schema.sql - run by Spring Boot at startup for an embedded database
create table book (
  id bigint generated by default as identity primary key,
  title varchar(255) not null,
  author varchar(255) not null,
  price int not null
);

create table purchase_order (
  id bigint generated by default as identity primary key,
  customer varchar(255) not null
);

create table order_line (
  purchase_order bigint not null references purchase_order(id),
  product varchar(255) not null,
  quantity int not null
);
Book.java - a record, used by both JdbcClient and Spring Data JDBC
//Book.java
package com.javabrahman.data.jdbc;

import org.springframework.data.annotation.Id;

public record Book(@Id Long id, String title, String author, int price) {
}
A Book is a Java record. JdbcClient maps query columns to its components by name; the @Id annotation is only for Spring Data JDBC, used later. Spring Boot creates a JdbcClient bean for the data source, which the BookCatalog class receives through its constructor -
BookCatalog.java - data access with JdbcClient
//BookCatalog.java
package com.javabrahman.data.jdbc;

import java.util.List;
import java.util.Optional;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;

@Repository
public class BookCatalog {
  private final JdbcClient jdbc;

  public BookCatalog(JdbcClient jdbc) {
    this.jdbc = jdbc;
  }

  public long add(String title, String author, int price) {
    KeyHolder key = new GeneratedKeyHolder();
    jdbc.sql("insert into book (title, author, price) values (:title, :author, :price)")
        .param("title", title)
        .param("author", author)
        .param("price", price)
        .update(key, "id");
    return key.getKey().longValue();
  }

  public List<Book> byAuthor(String author) {
    return jdbc.sql("select id, title, author, price from book where author = :author order by title")
        .param("author", author)
        .query(Book.class)          // columns mapped to the record's components by name
        .list();
  }

  public Optional<String> titleOf(long id) {
    return jdbc.sql("select title from book where id = ?")
        .param(id)
        .query(String.class)
        .optional();
  }

  public int raisePrices(String author, int amount) {
    return jdbc.sql("update book set price = price + :amount where author = :author")
        .param("amount", amount)
        .param("author", author)
        .update();                  // number of rows changed
  }

  public List<String> totalsPerAuthor() {
    return jdbc.sql("select author, count(*), sum(price) from book group by author order by author")
        .query((rs, row) -> rs.getString(1) + ": " + rs.getInt(2) + " books, total price " + rs.getInt(3))
        .list();
  }
}
Each call starts with sql(), binds parameters by name (:author) or by position (?), and ends with what to do: query(...) followed by list(), single() or optional(), or update(). Results can be mapped to a class, a single column type such as String, or with a lambda over the ResultSet.

The same book table can also be used through a Spring Data JDBC repository. ListCrudRepository provides save(), findById(), count() and the rest, returning lists, and derived query methods work as in Spring Data JPAClick to read tutorial: Spring Data JPA Derived Query Methods Explained -
BookRepository.java - a Spring Data JDBC repository for the same table
//BookRepository.java
package com.javabrahman.data.jdbc;

import java.util.List;
import org.springframework.data.repository.ListCrudRepository;

public interface BookRepository extends ListCrudRepository<Book, Long> {
  List<Book> findByPriceGreaterThanOrderByPrice(int price);
}
Spring Data JDBC is built around aggregates, an idea from domain-driven design: a cluster of objects that is always loaded and saved together through one root. Here, a PurchaseOrder is the root, and its OrderLines belong to it. There is no repository for order lines, and an order line has no id of its own. Spring Data JDBC finds the lines in the order_line table through a column named after the root's table, purchase_order. Relationships to other aggregates are held as plain ids, not object references -
OrderLine.java - part of the order aggregate, no id of its own
//OrderLine.java
package com.javabrahman.data.jdbc;

public record OrderLine(String product, int quantity) {
}
PurchaseOrder.java - the aggregate root
//PurchaseOrder.java
package com.javabrahman.data.jdbc;

import java.util.HashSet;
import java.util.Set;
import org.springframework.data.annotation.Id;

public class PurchaseOrder {
  @Id
  private Long id;
  private String customer;
  private Set<OrderLine> lines = new HashSet<>();

  public PurchaseOrder(String customer) {
    this.customer = customer;
  }

  public void add(String product, int quantity) {
    lines.add(new OrderLine(product, quantity));
  }

  public Long getId() { return id; }
  public String getCustomer() { return customer; }
  public Set<OrderLine> getLines() { return lines; }
}
OrderRepository.java
//OrderRepository.java
package com.javabrahman.data.jdbc;

import org.springframework.data.repository.CrudRepository;

public interface OrderRepository extends CrudRepository<PurchaseOrder, Long> {
}
JdbcApp.java - the same data through JdbcClient and Spring Data JDBC
//JdbcApp.java
package com.javabrahman.data.jdbc;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class JdbcApp {

  @Bean
  CommandLineRunner demo(BookCatalog catalog, BookRepository books, OrderRepository orders) {
    return args -> {
      System.out.println("== JdbcClient");
      long id = catalog.add("Effective Java", "Joshua Bloch", 45);
      catalog.add("Java Puzzlers", "Joshua Bloch", 30);
      catalog.add("Clean Code", "Robert Martin", 38);
      System.out.println("generated id: " + id);
      System.out.println("title of " + id + ": " + catalog.titleOf(id).orElse("-"));
      System.out.println("title of 99: " + catalog.titleOf(99).orElse("not found"));
      catalog.byAuthor("Joshua Bloch").forEach(System.out::println);
      System.out.println("rows updated: " + catalog.raisePrices("Joshua Bloch", 5));
      catalog.totalsPerAuthor().forEach(System.out::println);

      System.out.println("== Spring Data JDBC repository");
      Book saved = books.save(new Book(null, "Refactoring", "Martin Fowler", 50));
      System.out.println("saved: " + saved);
      System.out.println("count: " + books.count());
      books.findByPriceGreaterThanOrderByPrice(40).forEach(System.out::println);

      System.out.println("== Spring Data JDBC aggregate");
      PurchaseOrder order = new PurchaseOrder("Ann");
      order.add("keyboard", 1);
      order.add("mouse", 2);
      order = orders.save(order);
      order.add("monitor", 1);
      orders.save(order);
      PurchaseOrder loaded = orders.findById(order.getId()).orElseThrow();
      System.out.println("order " + loaded.getId() + " for " + loaded.getCustomer() + ": " + loaded.getLines().size() + " lines");
    };
  }

  public static void main(String[] args) {
    SpringApplication.run(JdbcApp.class, args);
  }
}
 OUTPUT of the above code
== JdbcClient
generated id: 1
title of 1: Effective Java
title of 99: not found
Book[id=1, title=Effective Java, author=Joshua Bloch, price=45]
Book[id=2, title=Java Puzzlers, author=Joshua Bloch, price=30]
rows updated: 2
Joshua Bloch: 2 books, total price 85
Robert Martin: 1 books, total price 38
== Spring Data JDBC repository
saved: Book[id=4, title=Refactoring, author=Martin Fowler, price=50]
count: 4
Book[id=1, title=Effective Java, author=Joshua Bloch, price=50]
Book[id=4, title=Refactoring, author=Martin Fowler, price=50]
== Spring Data JDBC aggregate
order 1 for Ann: 3 lines

Explanation of the JdbcClient and repository output

  • add() inserted a book and returned the generated id, 1, read through a KeyHolder with update(key, "id").
  • titleOf(1) found the title; titleOf(99) found no row, and optional() returned an empty Optional, printed as "not found". single() would have thrown an exception instead.
  • byAuthor() returned Book records built from the columns, and raisePrices() returned the number of rows changed, 2. The totals query mapped a group by result with a lambda. JdbcClient runs exactly the SQL you write, so aggregates, joins and database-specific SQL need no special support.
  • The repository's save() inserted a new Book record with a null id and returned a new record with id 4. A record is immutable, so Spring Data JDBC created a copy with the generated id. It got 4 because the JdbcClient inserts had used 1 to 3: both work on the same table.
  • findByPriceGreaterThanOrderByPrice(40) found Effective Java, now at 50 after the price rise, and Refactoring, also at 50.

How Spring Data JDBC saves an aggregate

The program saved an order with two lines, added a third line, saved again, and loaded the order. The SQL of that part, from Spring's JDBC debug log -  OUTPUT - the SQL of the aggregate
Executing prepared SQL statement [INSERT INTO "PURCHASE_ORDER" ("CUSTOMER") VALUES (?)]
Executing SQL batch update [INSERT INTO "ORDER_LINE" ("PRODUCT", "PURCHASE_ORDER", "QUANTITY") VALUES (?, ?, ?)]
Executing prepared SQL statement [INSERT INTO "ORDER_LINE" ("PRODUCT", "PURCHASE_ORDER", "QUANTITY") VALUES (?, ?, ?)]
Executing prepared SQL statement [UPDATE "PURCHASE_ORDER" SET "CUSTOMER" = ? WHERE "PURCHASE_ORDER"."ID" = ?]
Executing prepared SQL statement [DELETE FROM "ORDER_LINE" WHERE "ORDER_LINE"."PURCHASE_ORDER" = ?]
Executing SQL batch update [INSERT INTO "ORDER_LINE" ("PRODUCT", "PURCHASE_ORDER", "QUANTITY") VALUES (?, ?, ?)]
Executing prepared SQL statement [INSERT INTO "ORDER_LINE" ("PRODUCT", "PURCHASE_ORDER", "QUANTITY") VALUES (?, ?, ?)]
Executing prepared SQL statement [SELECT "PURCHASE_ORDER"."ID" AS "ID", "PURCHASE_ORDER"."CUSTOMER" AS "CUSTOMER" FROM "PURCHASE_ORDER" WHERE "PURCHASE_ORDER"."ID" = ?]
Executing prepared SQL statement [SELECT "ORDER_LINE"."PRODUCT" AS "PRODUCT", "ORDER_LINE"."QUANTITY" AS "QUANTITY" FROM "ORDER_LINE" WHERE "ORDER_LINE"."PURCHASE_ORDER" = ?]
order 1 for Ann: 3 lines
  • First save: one INSERT for the order, then the two lines in one batch update of the ORDER_LINE insert, with the new order's id in the PURCHASE_ORDER column. The log shows the batch, then the one statement prepared for it, which runs once per line.
  • Second save: Spring Data JDBC does not track what changed, since it has no persistence context. It updates the root, deletes all the order's lines, and inserts all three again in one batch. That is the price of its simplicity: the whole aggregate is written on every save. It is why aggregates should be kept small, and why a large collection, such as all the orders of a customer, belongs in its own aggregate, referenced by id.
  • Loading: one query for the order, and one for its lines. They are loaded at once, because there is no lazy loading in Spring Data JDBC. What you get back is a complete, plain object.
  • The SQL is generated from the names: classes and fields in camel case become table and column names in snake case (PurchaseOrder to PURCHASE_ORDER). @Table and @Column override them. Queries the derived methods cannot express go into @Query methods with plain SQL.

JdbcClient, Spring Data JDBC or JPA?

  • JPA with Spring Data JPA: rich domain models with many relationships, changes tracked automatically, caching, and the most features. It costs the most to understand: lazy loading, the persistence context, flush timing and generated SQL. It is still the most common choice in Spring applications.
  • Spring Data JDBC: repositories and derived queries with predictable behaviour. Each method call runs its SQL immediately, objects are plain, and there are no proxies. It fits well-designed small aggregates, and teams who prefer to see what happens. It has no lazy loading, no dirty checking, and no many-to-many mapping between aggregates.
  • JdbcClient: full control of the SQL. Best for reports, bulk updates, complex queries, database-specific features, and code where every statement matters. You write and maintain the SQL and the mapping yourself.
  • They combine well: an application can use JPA or Spring Data JDBC repositories for its domain, and a JdbcClient for the few heavy queries, all in the same transactions, since all of them use the same data source and Spring's transaction management.

Summary

In this tutorial we accessed a database without JPA in two ways. With JdbcClient, we inserted rows and read a generated key, mapped rows to records and single values, used optional() for a missing row, counted updated rows, and mapped an aggregate query with a lambda. With Spring Data JDBC, a repository saved an immutable record and returned a copy with its id, and a derived query ran as in Spring Data JPA. Saving a PurchaseOrder aggregate showed the order inserted with its lines in a batch, and the lines deleted and re-inserted on the next save. We compared the three approaches and saw that they can be combined. This completes the data-access module of the series. The next module covers building REST APIs with Spring MVC.