Data Access: JPA and Beyond¶
JPA/Hibernate is the default at 90% of Java shops, which means you have to know it — but knowing it well requires unlearning the “just add @Entity and go” mindset that ships bugs to production every week. This file covers JPA the way it survives contact with a real Postgres, plus the ecosystem around it (Flyway, HikariCP, jOOQ, JDBC) so you know when to abandon JPA rather than beat it into submission.
The uncomfortable truth: JPA is a productivity tool for the boring 70% of CRUD. For everything else — reporting queries, bulk updates, complex joins, performance-critical paths — dropping down to jOOQ or plain JDBC is not a defeat, it’s the pro move.
1. Entities: The Rules That Bite¶
@Entity
@Table(name = "urls", indexes = {
@Index(name = "idx_urls_code", columnList = "code", unique = true),
@Index(name = "idx_urls_created_at", columnList = "createdAt")
})
public class UrlEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 30)
private String code;
@Column(nullable = false, length = 2048)
private String targetUrl;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Version
private Long version; // optimistic locking
protected UrlEntity() {} // Hibernate needs this. protected, not public.
public UrlEntity(String code, String targetUrl, Instant createdAt) {
this.code = code;
this.targetUrl = targetUrl;
this.createdAt = createdAt;
}
// getters only; setters only where mutation is a real business operation
}
Rules that new devs miss:
No-arg constructor is mandatory (Hibernate uses reflection). Make it
protectedso app code can’t use it.Prefer
Longoverlongfor IDs — unassigned entities can then benull, which Hibernate uses to distinguish transient from persistent.Do not implement
equals/hashCodeon generated IDs alone — they’renullbefore flush, breakingSetmembership. Use a stable business key (e.g.,code) or don’t override at all if the entity’s identity is by reference.@Versionfor optimistic locking — concurrent updates get anOptimisticLockExceptioninstead of silent overwrites. Nearly free, always turn it on.Never use
@Entityclasses as DTOs. See 02_rest_apis_and_web_layer.md.
Records as entities?¶
Hibernate 6 supports Java records as embeddables (@Embeddable) but not as top-level entities — records are final and immutable, which conflicts with proxy-based lazy loading and dirty checking. Use classes for entities, records for embeddables and everywhere else.
2. Relationships: The N+1 Trap¶
@Entity
public class Order {
@Id private Long id;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY) // LAZY by default — keep it
private List<OrderItem> items;
}
The N+1 problem, in one study-ready sentence: for a query returning N parents, LAZY loading the same association on each triggers N additional queries.
// This looks innocent. It is not.
List<Order> orders = orderRepo.findAll(); // 1 query
for (Order o : orders) {
log.info("Order {} has {} items", o.getId(), o.getItems().size()); // N queries
}
Detection:
Turn on
spring.jpa.properties.hibernate.generate_statistics=trueand log statistics — counts will explode.Use Hypersistence Utils or Datasource Proxy to fail tests when queries exceed a threshold.
Read the Hibernate SQL log during dev (
spring.jpa.show-sql=true+ a real SQL formatter).
Fixes:
JOIN FETCHin JPQL — explicit, per-query:@Query("select o from Order o join fetch o.items where o.customerId = :cid") List<Order> findByCustomerWithItems(@Param("cid") Long cid);
@EntityGraphon the repository method — declarative:@EntityGraph(attributePaths = "items") List<Order> findByCustomerId(Long customerId);
@BatchSize(size = 50)on the collection — issues one query per batch instead of one per parent.DTO projection — skip entities entirely for read-heavy paths:
@Query("select new com.example.OrderSummary(o.id, count(i)) from Order o left join o.items i group by o.id") List<OrderSummary> summaries();
⚠️ What most people get wrong: Setting
fetch = FetchType.EAGERto “solve” N+1. This just makes every query pull everything, including relationships you didn’t ask for. EAGER is almost always the wrong answer. Keep LAZY, fetch what you need per-query.
3. @Transactional — Where and Why¶
Put
@Transactionalon service methods, not repositories, not controllers.Read-only? Add
@Transactional(readOnly = true). Hibernate skips dirty checking — real performance win on list endpoints.Self-invocation doesn’t work. Calling a
@Transactionalmethod from another method in the same class bypasses the proxy — no transaction. Extract to a different bean if you need cross-method transactionality.Rollback rules: default rolls back on
RuntimeExceptiononly. Checked exceptions do NOT roll back unless you specifyrollbackFor = Exception.class. This is a common bug.Propagation:
REQUIRED(default) is right 95% of the time.REQUIRES_NEWfor “log this action even if the outer transaction fails” audit patterns.
4. Spring Data JPA Repositories¶
public interface UrlRepository extends JpaRepository<UrlEntity, Long> {
Optional<UrlEntity> findByCode(String code);
// Derived query — method name becomes SQL
List<UrlEntity> findByCreatedAtAfterOrderByCreatedAtDesc(Instant after);
// Explicit JPQL
@Query("select u from UrlEntity u where u.clickCount > :min")
List<UrlEntity> popular(@Param("min") long min);
// Native SQL when JPQL isn't enough
@Query(value = "select code, target_url from urls where code = :code", nativeQuery = true)
Optional<UrlEntity> findByCodeNative(@Param("code") String code);
// Modifying — batch update, bypasses entity cache
@Modifying
@Query("update UrlEntity u set u.clickCount = u.clickCount + 1 where u.code = :code")
int incrementClicks(@Param("code") String code);
}
Derived queries are elegant for simple cases but become unreadable past ~4 predicates. When method names sprawl (findByCreatedAtBetweenAndTargetUrlContainingAndClickCountGreaterThanOrderByCreatedAtDesc), switch to @Query or Specifications.
5. When to Drop JPA¶
JPA is the wrong tool for:
Reporting queries with 6+ joins, window functions, CTEs.
Bulk operations (
UPDATE ... WHERE ...across millions of rows).Read-only projections where you don’t need identity/dirty checking.
Any query where SQL is what you’d naturally write.
The alternatives¶
Tool |
Style |
When |
|---|---|---|
jOOQ |
Type-safe SQL DSL, generated from schema |
You want SQL, in Java, without stringly-typed queries. The best pick for read-heavy or reporting-heavy services. |
Spring JDBC / JdbcClient (Spring 6.1+) |
Templated SQL, manual mapping |
Simple, fast, no ORM overhead. Ideal for a handful of custom queries alongside JPA. |
MyBatis |
SQL in XML (or annotations), manual mapping |
Popular in Asian enterprise stacks, especially Alibaba/Zoho-adjacent. Very explicit. |
Hibernate Reactive / R2DBC |
Non-blocking DB access |
Only if the entire stack is reactive. Do not mix reactive DB access with a servlet-based Spring MVC app. |
Honest recommendation: JPA for CRUD on aggregates, jOOQ for reads and reports in the same service. This hybrid is the sweet spot for most MNCs.
6. Flyway Migrations: Version Your Schema¶
Hibernate’s ddl-auto=update is the fastest way to nuke production. Turn it off. Use Flyway or Liquibase — Flyway is simpler, so use Flyway.
spring:
jpa:
hibernate:
ddl-auto: validate # validate schema matches entities on startup
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
-- src/main/resources/db/migration/V1__initial.sql
create table urls (
id bigserial primary key,
code varchar(30) not null unique,
target_url varchar(2048) not null,
click_count bigint not null default 0,
version bigint not null default 0,
created_at timestamptz not null
);
create index idx_urls_created_at on urls (created_at desc);
Rules:
Migrations are immutable — never edit a versioned migration once merged. Fix mistakes with a new migration.
Naming:
V{n}__snake_case_description.sql. Two underscores between version and name.Repeatable migrations (
R__seed_countries.sql) for reference data that changes with each schema iteration.Test migrations in Testcontainers before shipping — see 04_testing_the_full_stack.md.
7. HikariCP: The Connection Pool You’re Already Using¶
HikariCP is Spring Boot’s default. It’s already tuned for most workloads. The values that matter:
spring:
datasource:
hikari:
maximum-pool-size: 10 # start here. tune from metrics, not intuition.
minimum-idle: 10 # keep = max in prod. warm connections beat cold ones.
connection-timeout: 30000 # 30s to get a connection before failing
idle-timeout: 600000 # 10min
max-lifetime: 1800000 # 30min (must be < DB server's timeout)
leak-detection-threshold: 60000 # warn if a connection is held > 60s
Pool sizing math (the canonical HikariCP guidance):
connections = ((core_count * 2) + effective_spindle_count)
For a service on a 4-core node hitting a Postgres on SSD: ~10 connections. A bigger pool is not faster. Past a point, connections queue in the database and everything gets worse. Measure hikaricp_connections_active in Micrometer, not vibes.
8. Caching¶
Second-level cache (Hibernate/Ehcache/Redis): rarely worth the complexity in modern services. Cache invalidation across nodes is a distributed systems problem you don’t want.
Application-level caching with @Cacheable:
@Service
public class UrlLookupService {
@Cacheable(value = "urlByCode", key = "#code")
public Optional<UrlEntity> findByCode(String code) { ... }
@CacheEvict(value = "urlByCode", key = "#url.code")
public UrlEntity save(UrlEntity url) { ... }
}
Backed by Caffeine (in-process) or Redis (shared). See ../08_distributed_systems_applied_integration/03_caching_and_state.md for the distributed cache reality.
9. Common JPA Sins (a checklist)¶
EAGER fetching on
@OneToMany— always LAZY.Not using
@Transactional(readOnly = true)on read endpoints.Calling
.save()inside a loop of 10,000 items — flush + clear in batches, or drop to JDBC.Missing
@Version— silent lost updates.ddl-auto=updatein prod — replace with Flyway +validate.Exposing
@Entityas JSON — lazy-init exceptions in the wild.List<Entity>return types without pagination — unbounded result set.Repository returning
Stream<T>but caller doesn’t wrap in try-with-resources — connection leak.
Practice Exercises¶
Build a
Book/Authormany-to-one relationship. Write a repository method that returns books with authors and confirm via SQL log that it’s one query, not N+1.Add a
@Versionfield. Simulate concurrent updates in two test methods; assert one getsOptimisticLockException.Write a Flyway migration to add a
deleted_atsoft-delete column. Update the repository to filter it out.Add Micrometer, watch
hikaricp_connections_activeunder load, and observe what happens when you set pool size to 2.Stretch: Add jOOQ alongside JPA. Rewrite one “reporting” query in jOOQ and benchmark against the JPQL version.
Return to README.md · Previous: 02_rest_apis_and_web_layer.md · Next: 04_testing_the_full_stack.md