Course topics

By WebNest Studio

Spring Boot Tutorial

JdbcClient and Spring Data JDBC

JPA is powerful, but not every application needs a full ORM with a persistence context, lazy loading and dirty checking. Sometimes you want to write SQL yourself and map rows to records; sometimes you want repositories without the hidden magic. Spring offers two lighter options.

JdbcClient is a modern, fluent API for running SQL with named parameters and mapping results — the successor to direct JdbcTemplate use. Spring Data JDBC gives you repositories and aggregates with simple, predictable behaviour: every save is an immediate SQL statement and nothing is ever lazy-loaded. This lesson covers both and when to choose each over JPA.

JdbcClient Basics

Spring Boot auto-configures a JdbcClient bean whenever a DataSource exists. The fluent chain is sql(...) → param(...) → query(...) or update(). query(MyRecord.class) maps columns to record components by name (snake_case to camelCase), and .single(), .optional() and .list() choose the result shape. Parameters are always bound safely, never concatenated.

Inserts, Generated Keys and Batches

update() returns the affected row count. Pass a KeyHolder to retrieve generated ids. For bulk inserts, JdbcTemplate.batchUpdate or NamedParameterJdbcTemplate remain available and are much faster than individual statements.

Spring Data JDBC and Aggregates

Spring Data JDBC is built around Domain-Driven Design aggregates: an aggregate root (for example Order) and the entities it contains (OrderLine). Saving the root saves the whole aggregate; loading the root loads the whole aggregate. References to other aggregates are stored as ids (AggregateReference<Customer, Long>), not object links. There is no lazy loading, no caching and no dirty checking — what you call is what runs.

When to Choose What

Use JPA for rich domain models with many relationships and when your team knows it well. Use Spring Data JDBC for simpler, aggregate-oriented models where predictability matters. Use JdbcClient for reporting queries, database-specific SQL, and performance-critical paths — it also mixes happily with JPA in the same application and transaction.

Examples

Querying and updating with JdbcClient

Java
public record CourseRow(Long id, String slug, String title, BigDecimal price) {}

@Repository
public class CourseQueries {

    private final JdbcClient jdbc;

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

    public List<CourseRow> findCheaperThan(BigDecimal max) {
        return jdbc.sql("select id, slug, title, price from courses where price < :max order by price")
            .param("max", max)
            .query(CourseRow.class)
            .list();
    }

    public Optional<CourseRow> findBySlug(String slug) {
        return jdbc.sql("select id, slug, title, price from courses where slug = :slug")
            .param("slug", slug)
            .query(CourseRow.class)
            .optional();
    }

    public long create(String slug, String title, BigDecimal price) {
        KeyHolder keys = new GeneratedKeyHolder();
        jdbc.sql("insert into courses (slug, title, price) values (:slug, :title, :price)")
            .param("slug", slug)
            .param("title", title)
            .param("price", price)
            .update(keys, "id");
        return keys.getKeyAs(Long.class);
    }

    public int applyDiscount(int percent) {
        return jdbc.sql("update courses set price = round(price * (100 - :p) / 100.0, 2)")
            .param("p", percent)
            .update();
    }
}
Output
findCheaperThan(2000) -> [CourseRow[id=3, slug=html, title=HTML, price=999.00], CourseRow[id=1, slug=java-core, title=Java - Core, price=1499.00]]
create("spring-ai", "Spring AI", 2999) -> 12
applyDiscount(10) -> 12 rows updated

Custom row mapping and aggregate queries

Java
public record RevenueByCourse(String title, long orders, BigDecimal revenue) {}

public List<RevenueByCourse> revenueReport(LocalDate from) {
    return jdbc.sql("""
            select c.title, count(o.id) as orders, sum(o.amount) as revenue
            from orders o join courses c on c.id = o.course_id
            where o.created_at >= :from
            group by c.title
            order by revenue desc
            """)
        .param("from", from)
        .query((rs, rowNum) -> new RevenueByCourse(
            rs.getString("title"), rs.getLong("orders"), rs.getBigDecimal("revenue")))
        .list();
}
Output
RevenueByCourse[title=Spring Boot, orders=412, revenue=1235588.00]
RevenueByCourse[title=Java - Core, orders=390, revenue=584610.00]

Spring Data JDBC aggregate and repository

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

@Table("purchase_order")
public record PurchaseOrder(
        @Id Long id,
        AggregateReference<Customer, Long> customer,   // reference to another aggregate by id
        String status,
        @MappedCollection(idColumn = "purchase_order_id", keyColumn = "line_no")
        List<OrderLine> lines) {

    public PurchaseOrder withStatus(String newStatus) {
        return new PurchaseOrder(id, customer, newStatus, lines);
    }
}

@Table("order_line")
public record OrderLine(String product, int quantity) {}

public interface PurchaseOrderRepository extends ListCrudRepository<PurchaseOrder, Long> {

    List<PurchaseOrder> findByStatus(String status);

    @Modifying
    @Query("update purchase_order set status = :status where id = :id")
    boolean updateStatus(Long id, String status);
}

// usage
PurchaseOrder saved = repo.save(new PurchaseOrder(null, AggregateReference.to(7L), "NEW",
        List.of(new OrderLine("Spring Boot course", 1), new OrderLine("Workbook", 2))));
repo.save(saved.withStatus("PAID"));
Output
INSERT INTO purchase_order (customer, status) VALUES (7, 'NEW')
INSERT INTO order_line (purchase_order_id, line_no, product, quantity) VALUES (1, 0, 'Spring Boot course', 1)
INSERT INTO order_line (purchase_order_id, line_no, product, quantity) VALUES (1, 1, 'Workbook', 2)
UPDATE purchase_order SET customer = 7, status = 'PAID' WHERE id = 1
DELETE FROM order_line WHERE purchase_order_id = 1
INSERT INTO order_line ... (lines are rewritten because the aggregate is saved as a whole)

Common Mistakes

  • Concatenating values into SQL strings instead of using :named parameters.
  • Expecting Spring Data JDBC to lazy-load or track changes like JPA; you must call save() explicitly.
  • Modelling huge aggregates in Spring Data JDBC, so every save rewrites hundreds of child rows.
  • Using .single() when zero rows are possible; use .optional() instead.
  • Mixing JdbcClient writes with JPA entities in the same transaction without flushing, so JPA does not see changes yet.

Key Points to Remember

  • JdbcClient is the fluent, auto-configured API for SQL with named parameters and record mapping.
  • Use KeyHolder for generated keys and batch APIs for bulk inserts.
  • Spring Data JDBC offers repositories for aggregates with predictable, explicit SQL.
  • References between aggregates use AggregateReference ids, not object links.
  • Choose JPA for rich models, Spring Data JDBC for simple aggregates, JdbcClient for custom SQL.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.