Choosing Between JPA and JDBC
JPA saves work on rich domain models; JDBC gives you exact SQL. Learn where each pays off and how to use both safely in one app.
JPA: interface BookingRepository extends JpaRepository<Booking, Long> { ... }
JDBC: jdbcClient.sql("SELECT ...").param(...).query(Dto.class).list();
Both: same DataSource and @Transactional; flush() before a JDBC readSpring Boot gives you two ways to reach a relational database. Spring Data JPA with Hibernate maps objects to tables and writes most SQL for you. JdbcClient and JdbcTemplate run exactly the SQL you write and map rows into whatever you ask for. Neither is the modern one; they solve different problems, and many production services use both.
A side-by-side view
| JPA (Hibernate) | JDBC (JdbcClient) | |
|---|---|---|
| You write | Entities, repository interfaces, some JPQL | Every SQL statement |
| Changes saved | Automatically, by dirty checking at commit | Only when you run an UPDATE |
| Relationships | Mapped and loaded for you, lazily or eagerly | Joins you write and map yourself |
| Typical surprises | N+1 queries, lazy loading outside a transaction | Boilerplate, SQL scattered through the code |
| Best at | CRUD on aggregates with rules and relationships | Reports, bulk work, vendor features, hot paths |
When JPA earns its keep
Load an order, change two lines, add a payment, and the commit writes exactly what changed. Optimistic locking with @Version, auditing and derived queries come almost free. The price is that you must understand what Hibernate does: fetch plans, the persistence context, when it flushes. Teams that skip that learning ship N+1 queries and endpoints that load a thousand rows to show ten.
When JDBC is the better tool
Read-heavy endpoints that return a flat DTO, reports with window functions or common table expressions, INSERT ... ON CONFLICT upserts, and batch jobs that touch thousands of rows. Here an entity graph is pure overhead: you want one query that returns exactly the columns the response needs. JDBC code is also easy to reason about, because nothing happens unless you wrote it.
Using both in one service
With both starters, Boot gives you one DataSource and a JpaTransactionManager, and JdbcClient takes part in the same transactions. A common split: JPA repositories for commands, JdbcClient for queries and reports.
The trap is the persistence context. Hibernate holds changed entities in memory and only flushes them at commit or before its own queries. A JdbcClient query in the same transaction does not trigger that flush, so it reads the old values. Call flush() on the repository before a JDBC read that depends on earlier JPA changes. The reverse also bites: after a JDBC UPDATE, entities already loaded in the transaction are stale.
Between the two sits Spring Data JDBC, which keeps repositories and aggregates but drops lazy loading and the persistence context. It is worth a look for simple aggregates, but not covered here.
Example
import java.time.LocalDate;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
class Booking {
(strategy = GenerationType.IDENTITY)
Long id;
String guestName;
LocalDate checkIn;
int nights;
boolean cancelled;
protected Booking() {}
Booking(String guestName, LocalDate checkIn, int nights) {
this.guestName = guestName;
this.checkIn = checkIn;
this.nights = nights;
}
void cancel() {
this.cancelled = true;
}
}
interface BookingRepository extends JpaRepository<Booking, Long> {}
record NightsByMonth(int month, long nights) {}
public class BookingService {
private final BookingRepository bookings;
private final JdbcClient jdbc;
public BookingService(BookingRepository bookings, JdbcClient jdbc) {
this.bookings = bookings;
this.jdbc = jdbc;
}
public List<NightsByMonth> cancelAndReport(long bookingId, int year) {
bookings.findById(bookingId).ifPresent(Booking::cancel);
// Without this, the JDBC query below still sees the booking as active.
bookings.flush();
return jdbc.sql("""
SELECT extract(month FROM check_in)::int AS month, sum(nights) AS nights
FROM booking
WHERE NOT cancelled AND extract(year FROM check_in) = :year
GROUP BY 1
ORDER BY 1""")
.param("year", year)
.query(NightsByMonth.class)
.list();
}
}
When to use it
- Keep booking creation and cancellation on JPA entities while serving the admin dashboard from JdbcClient queries.
- Replace a slow report built from entity graphs with one SQL query using window functions.
- Add an atomic counter upsert to a JPA service without loading and saving the row.
More examples
Commands with JPA, a read model with JDBC
The list screen needs four columns from two tables. Loading Booking entities and walking to their Room would load more columns and risk an extra query per row. One explicit join that returns the DTO directly is cheaper and needs no fetch tuning.
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
record BookingListItem(long id, String guestName, String roomName, int nights) {}
@Repository
@Transactional(readOnly = true)
public class BookingListQuery {
private final JdbcClient jdbc;
public BookingListQuery(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public List<BookingListItem> forGuest(String guestName) {
return jdbc.sql("""
SELECT b.id, b.guest_name, r.name AS room_name, b.nights
FROM booking b
JOIN room r ON r.id = b.room_id
WHERE b.guest_name = :guestName
ORDER BY b.check_in DESC""")
.param("guestName", guestName)
.query(BookingListItem.class)
.list();
}
}
An atomic upsert in one statement
The usual entity approach loads the row, increments it in Java and saves it: two round trips, and a lost update when two requests race. JPQL has no ON CONFLICT, so with JPA you would end up writing this native SQL anyway. The database does the increment atomically in one statement. Database-specific features like this are a strong reason to drop to JDBC for a single operation.
import java.time.LocalDate;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public class DailyStatsWriter {
private final JdbcClient jdbc;
public DailyStatsWriter(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public void recordBooking(long roomId, LocalDate day) {
jdbc.sql("""
INSERT INTO room_daily_stats (room_id, day, bookings)
VALUES (:roomId, :day, 1)
ON CONFLICT (room_id, day)
DO UPDATE SET bookings = room_daily_stats.bookings + 1""")
.param("roomId", roomId)
.param("day", day)
.update();
}
}
Checking the choice with a test
Hand-written SQL is only checked when it runs, and ON CONFLICT does not exist in H2's default mode. Testing against real PostgreSQL in a container catches syntax and behaviour differences that an in-memory database would hide. In a real project, Flyway migrations would create the table instead of the test.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.JdbcTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
@JdbcTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class DailyStatsSqlTest {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
@Autowired
JdbcClient jdbc;
@Test
void upsertIncrementsExistingRow() {
jdbc.sql("CREATE TABLE room_daily_stats (room_id BIGINT, day DATE, bookings INT, PRIMARY KEY (room_id, day))")
.update();
String upsert = """
INSERT INTO room_daily_stats VALUES (1, DATE '2026-10-13', 1)
ON CONFLICT (room_id, day) DO UPDATE SET bookings = room_daily_stats.bookings + 1""";
jdbc.sql(upsert).update();
jdbc.sql(upsert).update();
int bookings = jdbc.sql("SELECT bookings FROM room_daily_stats WHERE room_id = 1")
.query(Integer.class)
.single();
assertThat(bookings).isEqualTo(2);
}
}
Discussion