03-data-access

JdbcClient: When Dropping the ORM Is the Simpler Choice

Fluent SQL with JdbcClient, mapping results with RowMapper, named parameters, and an honest account of when JPA is the wrong tool.

October 9, 2026
spring-bootjdbcJdbcClientJdbcTemplatesqlRowMapperdata-access

Not Every Query Wants an ORM

JPA is excellent at what it was designed for: loading an object graph, mutating it, and writing the changes back. It is a poor fit for several things that come up constantly.

  • Reporting queries — six joins, three aggregates, a window function, and no entity in sight.
  • Bulk operations — update orders set status = 'ARCHIVED' where placed_at < ? as one statement rather than loading a million entities.
  • Database-specific SQL — Postgres jsonb operators, insert ... on conflict, CTEs.
  • Read models — a query whose result shape exists only to be serialised.

You can force these through JPA with native queries, and the result is SQL in a string with none of JPA's benefits and all of its indirection. Using the JDBC layer directly is not a regression here; it's the right tool.

Both can coexist in one application — same DataSource, same transaction, same @Transactional boundary. This is not an architectural commitment.

JdbcClient

Spring 6.1 added JdbcClient, a fluent API over the older templates. It's the one to use for new code.

java
@Repository
public class OrderReportRepository {
 
    private final JdbcClient jdbc;
 
    public OrderReportRepository(JdbcClient jdbc) {       // auto-configured
        this.jdbc = jdbc;
    }
 
    public List<DailyRevenue> revenueByDay(LocalDate from, LocalDate to) {
        return jdbc.sql("""
                   select date_trunc('day', placed_at) as day,
                          count(*)                     as order_count,
                          sum(total)                   as revenue
                   from orders
                   where placed_at >= :from and placed_at < :to
                   group by 1
                   order by 1
                   """)
                .param("from", from)
                .param("to", to)
                .query(DailyRevenue.class)
                .list();
    }
}

The terminal operations cover the shapes you need:

java
jdbc.sql("select * from orders where id = :id").param("id", id)
    .query(Order.class).single();        // exactly one, or throws
    .query(Order.class).optional();      // Optional
    .query(Order.class).list();          // List
    .query(rowMapper).list();            // custom mapping
 
jdbc.sql("update orders set status = :s where id = :id")
    .param("s", "PAID").param("id", id)
    .update();                           // affected row count

query(SomeRecord.class) maps columns to constructor parameters, converting snake_case to camelCase. For a record whose component names match the columns, that's all the mapping you need.

✅

JdbcClient is auto-configured whenever a DataSource exists — spring-boot-starter-jdbc or starter-data-jpa both give you one. Inject it; don't construct it.

Named Parameters, Always

The older JdbcTemplate uses positional ? parameters:

java
jdbcTemplate.update(
    "update orders set status = ?, total = ?, customer_name = ? where id = ?",
    status, total, customerName, id);

Four positional parameters, and a bug waiting for the first person who reorders the SET clause without reordering the arguments. The types often still line up, so it compiles, runs, and writes the wrong column.

Named parameters remove the failure mode:

java
jdbc.sql("update orders set status = :status, total = :total where id = :id")
    .param("status", status)
    .param("total", total)
    .param("id", id)
    .update();

JdbcClient supports both and you should use names exclusively. If you're on an older codebase, NamedParameterJdbcTemplate gives the same benefit.

🚨

Never build SQL by concatenating input. "select * from orders where status = '" + status + "'" is SQL injection, and no amount of upstream validation makes it safe. Parameters are not a style preference — they're the mechanism that keeps values as values. This includes ORDER BY columns from a request: a column name can't be a bind parameter, so validate it against an allow-list of permitted column names rather than interpolating it.

Mapping Results

Record mapping handles most cases, as above.

RowMapper when the shape doesn't match a constructor:

java
private static final RowMapper<Order> ORDER_MAPPER = (rs, rowNum) -> new Order(
        rs.getLong("id"),
        rs.getString("customer_name"),
        rs.getBigDecimal("total"),
        OrderStatus.valueOf(rs.getString("status")),
        rs.getObject("placed_at", OffsetDateTime.class).toInstant());

Two details: rs.getObject(col, Type.class) is the correct way to read java.time values — getTimestamp drags in java.sql.Timestamp and its timezone behaviour. And for a nullable numeric column, rs.getLong returns 0 for SQL NULL; use rs.getObject("col", Long.class) to get a real null.

BeanPropertyRowMapper maps columns to setters by name. Convenient, reflective, and silently tolerant — a renamed column leaves a field null rather than failing. Prefer records or explicit mappers.

Joins returning a parent with children need an extra step, because SQL gives you duplicated parent rows:

java
public Optional<OrderWithLines> findWithLines(Long id) {
    Map<Long, OrderWithLines> byId = new LinkedHashMap<>();
    jdbc.sql("""
             select o.id, o.customer_name, l.sku, l.quantity
             from orders o
             left join order_lines l on l.order_id = o.id
             where o.id = :id
             """)
        .param("id", id)
        .query((rs, rowNum) -> {
            OrderWithLines order = byId.computeIfAbsent(rs.getLong("id"),
                    k -> new OrderWithLines(k, rs.getString("customer_name"), new ArrayList<>()));
            String sku = rs.getString("sku");
            if (sku != null) {                    // left join: no lines → null
                order.lines().add(new Line(sku, rs.getInt("quantity")));
            }
            return order;
        })
        .list();
    return byId.values().stream().findFirst();
}

Verbose compared to @OneToMany, and worth seeing once: this is the work JPA does for you, and the reason an ORM exists. For a read model with a known shape it's a fair trade — one query, no lazy loading, no persistence context.

Batch Updates

For many writes of the same shape, batching turns N round trips into one:

java
public void insertAll(List<OrderLine> lines) {
    jdbc.sql("insert into order_lines (order_id, sku, quantity) values (:orderId, :sku, :quantity)")
        .paramSource(lines)
        .update();
}

The performance difference is not marginal — thousands of individual inserts versus a handful of batches is often two orders of magnitude. If you're inserting more than a few dozen rows, batch. (And if you're inserting a million, look at your database's bulk-load path — COPY on Postgres — before optimising the JDBC route.)

Check yourself

A repository method reads a nullable integer column with rs.getInt('discount_percent') and returns it as Integer. Rows with SQL NULL come back as 0. Why?

Using Both Together

A pragmatic split that works well:

java
@Service
public class OrderService {
 
    private final OrderRepository orders;            // JPA — writes, entity graph
    private final OrderReportRepository reports;     // JdbcClient — reads, aggregates
 
    @Transactional
    public Order place(NewOrderRequest request) {
        return orders.save(request.toOrder());       // entity-shaped write
    }
 
    @Transactional(readOnly = true)
    public List<DailyRevenue> revenue(LocalDate from, LocalDate to) {
        return reports.revenueByDay(from, to);       // report-shaped read
    }
}

Both participate in the same Spring transaction, because both use the same DataSource and the same transaction manager. There is no coordination to configure.

⚠️

One real hazard when mixing: JPA defers writes until flush, so a JdbcClient query in the same transaction may not see changes Hibernate hasn't flushed yet. Call entityManager.flush() first, or order operations so reads precede writes. This is the shape of bug that passes every unit test and fails intermittently in integration.

Choosing Between Them

Use JPA whenUse JdbcClient when
Loading and mutating an object graphReporting, aggregates, analytics
CRUD on entities with relationshipsBulk inserts, updates, deletes
Changes tracked and written on flushDatabase-specific SQL
The domain model is the schemaThe result shape exists only to serialise

The honest summary: JPA for writes and entity work, SQL for reads that aren't entity-shaped. This is close to the CQRS instinct without the infrastructure — one database, two access styles chosen per operation.

If you like repositories and derived queries but not the entity lifecycle, Spring Data JDBC is a third option: a simpler persistence model with no lazy loading, no dirty checking and no proxies, where aggregates are loaded and saved whole. Less powerful than JPA, and considerably easier to predict.

Check yourself

A nightly job archives orders older than two years — roughly 4 million rows. The JPA implementation loads them as entities and calls delete() on each. What is the better approach?

The Mental Model, Restated

  1. JPA and JDBC coexist — same DataSource, same transaction, chosen per operation.
  2. JdbcClient for new code; NamedParameterJdbcTemplate for existing.
  3. Named parameters always. Never concatenate input, and allow-list dynamic column names.
  4. Records map themselves; use a RowMapper when the shape differs, and getObject(col, Type.class) for nullables and java.time.
  5. Batch multi-row writes. The difference is orders of magnitude.
  6. JPA for entity-shaped writes, SQL for everything else.

What's Next

Both access styles assume the schema already exists in the shape the code expects. Keeping that true across environments and deployments is its own discipline. The next guide covers Flyway: versioned migrations, what makes a migration safe to run against a live system, and why ddl-auto must never be the mechanism in production.