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.
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
jsonboperators,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.
@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:
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 countquery(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:
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:
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:
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:
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:
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:
@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 when | Use JdbcClient when |
|---|---|
| Loading and mutating an object graph | Reporting, aggregates, analytics |
| CRUD on entities with relationships | Bulk inserts, updates, deletes |
| Changes tracked and written on flush | Database-specific SQL |
| The domain model is the schema | The 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
- JPA and JDBC coexist — same
DataSource, same transaction, chosen per operation. JdbcClientfor new code;NamedParameterJdbcTemplatefor existing.- Named parameters always. Never concatenate input, and allow-list dynamic column names.
- Records map themselves; use a
RowMapperwhen the shape differs, andgetObject(col, Type.class)for nullables andjava.time. - Batch multi-row writes. The difference is orders of magnitude.
- 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.