Skip to main content

Week 5 Day 5 — JPA Performance and Best Practices

Goal

Today I want to understand practical JPA performance and persistence best practices.

Main questions:

  1. Why can JPA performance become a problem?
  2. What is over-fetching?
  3. What is under-fetching?
  4. What is a fetch plan?
  5. How do I avoid N+1 queries?
  6. What are projections?
  7. What are DTO projections?
  8. What is batch fetching?
  9. How should I design repository methods?
  10. How do I test repositories?
  11. What is @DataJpaTest?
  12. What are common persistence bugs?

1. Quick Review from Week 5 Day 4

In Day 4, I learned:

  • Entity relationships map Java object references to database foreign keys.
  • @ManyToOne usually belongs on the child side.
  • @OneToMany(mappedBy = "...") usually represents the inverse parent collection.
  • The owning side usually has @JoinColumn.
  • mappedBy uses Java field names, not database column names.
  • Lazy loading delays loading related data.
  • Eager loading loads related data immediately.
  • @ManyToOne is eager by default.
  • N+1 means one main query plus many extra relationship queries.
  • Fetch join, entity graph, and DTO projection can help avoid N+1.
  • REST APIs should usually return DTOs, not entities.

Memory sentence:

Entity relationships are powerful, but they can easily create performance and serialization problems.

Today I learn how to avoid common JPA performance bugs.


2. Why JPA Performance Can Become a Problem

JPA is convenient because I work with objects.

But the database still works with:

tables
rows
columns
joins
indexes
SQL queries
transactions
locks

Problems happen when I forget that object access can trigger SQL.

Example:

task.getClient().getName();

This looks like a simple Java getter.

But if client is lazy, it may trigger:

select * from clients where id = ?

Memory sentence:

With JPA, simple Java property access can trigger database queries.


3. The Main Performance Problems

Common JPA performance problems:

N+1 queries
over-fetching too much data
under-fetching and triggering extra queries
loading huge tables without pagination
returning entities directly from APIs
lazy loading during JSON serialization
using eager relationships everywhere
missing indexes
too many writes inside long transactions
external API calls inside transactions
inefficient repository methods

Today’s focus:

fetching
projections
pagination
batch fetching
testing
common bugs

4. Over-Fetching

Over-fetching means:

Loading more data than the use case needs.

Example:

API only needs:

{
"id": 1,
"title": "Prepare tax documents"
}

But repository loads:

task
client
comments
attachments
audit logs
creator
assignee

Problem:

more SQL work
more memory usage
more network transfer
slower response
more JSON risk if entities are returned

Memory sentence:

Over-fetching means loading too much.


5. Under-Fetching

Under-fetching means:

Loading too little data first, then triggering extra queries later.

Example:

List<TaskEntity> tasks = taskRepository.findByStatus("OPEN");

for (TaskEntity task : tasks) {
System.out.println(task.getClient().getName());
}

First query:

select * from tasks where status = 'OPEN';

Then one query per task:

select * from clients where id = ?;

This creates N+1.

Memory sentence:

Under-fetching often creates N+1 queries.


6. Fetch Plan

A fetch plan means:

A decision about which data should be loaded for a use case.

Example endpoint:

GET /api/tasks

Maybe needs:

task id
task title
task status
client name

It does not need:

client address
client invoices
task comments
attachments
audit logs

Good fetch plan:

Load only task fields and client name.

Memory sentence:

Fetch plan means load exactly what the use case needs.


7. Bad Fetch Plan Example

Bad entity design:

@ManyToOne(fetch = FetchType.EAGER)
private ClientEntity client;

@OneToMany(mappedBy = "task", fetch = FetchType.EAGER)
private List<CommentEntity> comments;

Problem:

Every task query loads client and comments even when not needed.

This creates:

large SQL queries
duplicate rows
more memory usage
performance surprises

Better:

@ManyToOne(fetch = FetchType.LAZY)
private ClientEntity client;

@OneToMany(mappedBy = "task")
private List<CommentEntity> comments = new ArrayList<>();

Then fetch explicitly when needed.

Memory sentence:

Prefer lazy relationships and explicit fetching for each use case.


8. Three Common Fetch Strategies

To load related data intentionally, use:

fetch join
@EntityGraph
DTO projection

Comparison:

StrategyGood for
Fetch joinloading entity plus needed relationships
@EntityGraphdefining association fetch plan on repository method
DTO projectionloading only fields needed by API
Batch fetchingreducing extra lazy loading queries

Memory sentence:

Choose the fetch strategy based on the use case.


9. Fix N+1 with Fetch Join

Problem:

List<TaskEntity> tasks = taskRepository.findByStatus("OPEN");

return tasks.stream()
.map(task -> new TaskDto(
task.getId(),
task.getTitle(),
task.getClient().getName()
))
.toList();

Potential N+1:

1 query for tasks
N queries for clients

Fix:

@Query("""
select t
from TaskEntity t
join fetch t.client
where t.status = :status
""")
List<TaskEntity> findByStatusWithClient(@Param("status") String status);

Now tasks and clients are loaded together.

Memory sentence:

Fetch join loads the relationship in the same query.


10. join vs join fetch

Normal join:

@Query("""
select t
from TaskEntity t
join t.client c
where c.name = :clientName
""")
List<TaskEntity> findByClientName(@Param("clientName") String clientName);

This joins for filtering.

Fetch join:

@Query("""
select t
from TaskEntity t
join fetch t.client
where t.status = :status
""")
List<TaskEntity> findByStatusWithClient(@Param("status") String status);

This joins and loads client.

Memory sentence:

join helps filter. join fetch also loads the association.


11. Fix N+1 with @EntityGraph

Repository:

@EntityGraph(attributePaths = "client")
List<TaskEntity> findByStatus(String status);

Meaning:

When finding tasks by status, also fetch client.

Good when I want to keep a derived query method but customize fetching.

Example:

public interface TaskRepository extends JpaRepository<TaskEntity, Long> {

@EntityGraph(attributePaths = "client")
List<TaskEntity> findByStatus(String status);
}

Memory sentence:

@EntityGraph defines what associations should be fetched.


12. Fetch Join vs EntityGraph

TopicFetch Join@EntityGraph
Where definedJPQL queryannotation on repository method
Controlexplicit queryfetch plan on method
Good forcustom JPQLderived queries with fetch needs
Readabilityclear querycleaner method
Riskcomplex pagination issuesstill must understand fetching

Both are useful.


13. DTO Projection

DTO projection means:

Query directly into a DTO instead of loading full entities.

DTO:

public record TaskListDto(
Long id,
String title,
String status,
String clientName
) {
}

Repository:

@Query("""
select new de.klarsync.tasks.TaskListDto(
t.id,
t.title,
t.status,
c.name
)
from TaskEntity t
join t.client c
where t.status = :status
order by t.createdAt desc
""")
List<TaskListDto> findTaskListByStatus(@Param("status") String status);

This loads only:

task id
task title
task status
client name

Memory sentence:

DTO projection loads only what the API needs.


14. Why DTO Projection Can Be Faster

Entity loading may load:

all entity columns
entity state tracking
relationships if fetched
persistence context management

DTO projection loads:

selected fields only
no managed entity tracking for DTO
API-ready shape

Good for:

list endpoints
read-only views
dashboards
search results
summary pages

Memory sentence:

For read-only list endpoints, DTO projection is often a good option.


15. Interface Projection

Spring Data can also use interface projections.

Projection:

public interface TaskSummaryView {

Long getId();

String getTitle();

String getStatus();
}

Repository:

List<TaskSummaryView> findByStatus(String status);

Spring Data returns objects that implement the interface projection.

This is useful for simple partial views.

Memory sentence:

Interface projections expose only selected getters.


16. DTO Projection vs Interface Projection

TopicDTO ProjectionInterface Projection
Typeclass/recordinterface
Constructoryesno
Good forclear API DTOsquick partial views
Query styleoften JPQL constructor expressionoften derived query support
Controlvery explicitconvenient

For learning and clean API design:

DTO records are usually easy to understand.

17. Projection Trap

Projection does not automatically solve all performance problems.

Example:

public interface TaskView {
Long getId();
String getTitle();
ClientView getClient();
}

Nested projections can still trigger joins or extra loading depending on the query and provider behavior.

Exam-safe memory:

Projection helps reduce selected data, but always check generated SQL for important queries.

18. Pagination Is a Performance Tool

Bad:

List<TaskEntity> findAll();

for large table.

Better:

Page<TaskEntity> findAll(Pageable pageable);

or:

Page<TaskListDto> findTaskListByStatus(String status, Pageable pageable);

Why?

limits rows loaded
reduces memory usage
improves response time
supports UI/API paging

Memory sentence:

Large list endpoints should use pagination.


19. Page vs Slice Performance

Page<T> gives:

content
total elements
total pages
page number
page size

But it usually needs a count query.

Slice<T> gives:

content
whether next slice exists

It does not need total count.

Use Slice when:

infinite scroll
load more button
no need for total pages

Memory sentence:

Page has totals. Slice can be lighter.


20. Fetch Join with Pagination Trap

Dangerous:

@Query("""
select c
from ClientEntity c
join fetch c.tasks
""")
Page<ClientEntity> findAllWithTasks(Pageable pageable);

Problem:

one client can have many tasks
join creates duplicate client rows
pagination may become incorrect or inefficient

Safer options:

use DTO projection
fetch IDs first, then fetch relationships
use batch fetching
avoid collection fetch join with pagination
use EntityGraph carefully
design endpoint differently

Memory sentence:

Be careful paginating queries that fetch join collections.


21. Batch Fetching

Batch fetching is a Hibernate optimization.

Simple definition:

Batch fetching loads multiple lazy associations in batches instead of one by one.

Without batch fetching:

1 query for tasks
1 query per client

With batch fetching, Hibernate may load clients in groups:

select * from clients where id in (?, ?, ?, ?, ?);

This reduces many small queries.

Memory sentence:

Batch fetching reduces N+1 by grouping lazy loads.


22. Global Batch Fetch Size

Example configuration:

spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 50

Meaning:

Hibernate can batch lazy association loading up to 50 IDs at a time.

This does not replace good query design.

It is a safety/performance optimization.

Memory sentence:

Batch fetching helps, but explicit fetch plans are still important.


23. @BatchSize

Hibernate-specific annotation:

@BatchSize(size = 50)
@ManyToOne(fetch = FetchType.LAZY)
private ClientEntity client;

or on collection:

@BatchSize(size = 50)
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks = new ArrayList<>();

This tells Hibernate to batch load this association.

Note:

@BatchSize is Hibernate-specific, not pure JPA.

Memory sentence:

@BatchSize is a Hibernate-specific batch loading hint.


24. Batch Fetching vs Fetch Join

TopicBatch FetchingFetch Join
Howgroups lazy loadsloads association in same query
Good forreducing N+1 globallyspecific use case query
Query countfewer queriesoften one query
Controlless explicitexplicit
PortabilityHibernate-specificJPQL/JPA concept
Use togetherpossiblepossible carefully

Memory sentence:

Fetch join is explicit. Batch fetching is a useful backup optimization.


25. Select Only What You Need

Bad list endpoint:

List<TaskEntity> findByStatus(String status);

Then maps many fields and relationships.

Better list endpoint:

@Query("""
select new de.klarsync.tasks.TaskListDto(
t.id,
t.title,
t.status,
c.name
)
from TaskEntity t
join t.client c
where t.status = :status
""")
Page<TaskListDto> findTaskList(
@Param("status") String status,
Pageable pageable
);

Memory sentence:

List endpoints usually need summary data, not full entity graphs.


26. Use Index-Friendly Queries

This is more database-side, but important.

Query:

where status = ?
order by created_at desc

Useful index might be:

create index idx_tasks_status_created_at
on tasks(status, created_at desc);

JPA cannot magically fix missing database indexes.

Memory sentence:

Good JPA queries still need good database indexes.


27. Avoid Functions on Indexed Columns

Less index-friendly:

where lower(title) like lower('%tax%')

Can be slower on large tables.

Options:

use database-specific text indexes
use normalized search column
use full-text search
use proper search engine for complex search

For normal simple apps, ContainingIgnoreCase is okay.

For large data, search needs design.

Memory sentence:

Simple search methods are fine for small data, but large search needs index strategy.


28. Avoid findAll() in Real APIs

Bad:

@GetMapping("/api/tasks")
public List<TaskDto> list() {
return taskRepository.findAll()
.stream()
.map(this::toDto)
.toList();
}

Problems:

loads entire table
slow response
high memory usage
hard to scale

Better:

@GetMapping("/api/tasks")
public Page<TaskDto> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return taskService.list(page, size);
}

Memory sentence:

Public list APIs should usually be paginated.


29. Keep Transactions Short

Bad:

@Transactional
public void createTask(CreateTaskRequest request) {
TaskEntity task = taskRepository.save(new TaskEntity(request.title()));
externalNotificationApi.send(task.getId());
fileStorage.upload(request.file());
}

Problem:

transaction stays open during slow network calls
database locks/resources held longer
external side effect may succeed but DB transaction may roll back

Better:

save data in transaction
commit
publish event
process external side effect after commit
use outbox pattern for reliability

Memory sentence:

Keep database transactions short and avoid slow external calls inside them.


30. Avoid Lazy Loading During JSON Serialization

Bad:

@GetMapping("/api/clients/{id}")
public ClientEntity getClient(@PathVariable Long id) {
return clientRepository.findById(id).orElseThrow();
}

Jackson may access lazy relationships during serialization.

Problems:

LazyInitializationException
N+1 during serialization
huge response
infinite recursion
sensitive fields leaked

Better:

@GetMapping("/api/clients/{id}")
public ClientDetailDto getClient(@PathVariable Long id) {
return clientService.getClientDetail(id);
}

Memory sentence:

Map entities to DTOs before returning from controllers.


31. Open Session in View

Open Session in View can keep the persistence context open during web request rendering.

This may hide lazy loading errors.

But it can cause:

SQL queries during JSON serialization
hidden N+1 problems
unclear transaction boundaries
performance surprises

Good practice:

fetch needed data in service
map to DTO in service
return DTO
do not rely on serialization-time lazy loading

Memory sentence:

Do not use Open Session in View as a design strategy.


32. Repository Method Design

Good repository methods are:

specific
readable
use entity property names
return correct type
support pagination for large data
avoid very long derived names
use @Query when clearer

Good:

Page<TaskEntity> findByClientIdAndStatus(
Long clientId,
String status,
Pageable pageable
);

Too long:

List<TaskEntity> findByClientIdAndStatusAndPriorityAndDueDateBeforeAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(
Long clientId,
String status,
String priority,
LocalDate dueDate,
String keyword
);

Better:

@Query("""
select t
from TaskEntity t
where t.clientId = :clientId
and t.status = :status
and t.priority = :priority
and t.dueDate < :dueDate
and lower(t.title) like lower(concat('%', :keyword, '%'))
order by t.createdAt desc
""")
List<TaskEntity> searchTasks(...);

Memory sentence:

Derived queries are good until the method name becomes unreadable.


33. Repository Return Type Best Practices

Use:

Optional<T> for zero or one result
List<T> for small/controlled many results
Page<T> for paginated results with totals
Slice<T> for load-more results without totals
boolean for exists queries
long for count queries
DTO projection for read-only API views

Examples:

Optional<UserEntity> findByEmail(String email);

boolean existsByEmail(String email);

long countByStatus(String status);

Page<TaskListDto> findTaskList(..., Pageable pageable);

34. Use existsBy... Instead of Loading Entity

Bad:

Optional<UserEntity> user = userRepository.findByEmail(email);
if (user.isPresent()) {
throw new EmailAlreadyUsedException(email);
}

Better:

if (userRepository.existsByEmail(email)) {
throw new EmailAlreadyUsedException(email);
}

Why?

database only checks existence
no need to load full entity
clear intent

Memory sentence:

Use existsBy... when I only need to know if a row exists.


35. Use Count Carefully

Counting can be expensive on large tables.

Example:

long total = taskRepository.countByStatus("OPEN");

This is okay when needed.

But avoid unnecessary count queries.

For pagination:

Page needs count
Slice can avoid count

Memory sentence:

Count queries are useful, but not free.


36. Bulk Updates and Persistence Context Trap

Bulk update:

@Modifying
@Query("update TaskEntity t set t.status = 'DONE' where t.clientId = :clientId")
int completeAllTasksForClient(@Param("clientId") Long clientId);

Important:

Bulk JPQL updates bypass normal dirty checking for already managed entities.

If entities are already loaded in the persistence context, they may become stale.

Options:

@Modifying(clearAutomatically = true, flushAutomatically = true)

Example:

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update TaskEntity t set t.status = 'DONE' where t.clientId = :clientId")
int completeAllTasksForClient(@Param("clientId") Long clientId);

Memory sentence:

Bulk updates bypass managed entity dirty checking and can make persistence context stale.


37. Delete Methods Can Be Dangerous

Dangerous:

void deleteByClientId(Long clientId);

This may delete many rows.

Better service check:

@Transactional
public void deleteClientTasks(Long clientId) {
ClientEntity client = clientRepository.findById(clientId)
.orElseThrow(() -> new ResourceNotFoundException("Client", clientId));

if (!client.canDeleteTasks()) {
throw new BusinessConflictException("Client tasks cannot be deleted");
}

taskRepository.deleteByClientId(clientId);
}

Memory sentence:

Bulk delete operations need business protection.


38. Testing Repositories

Repository tests check:

entity mapping
derived query methods
@Query methods
constraints
persistence behavior
relationships
pagination
sorting

Spring Boot provides:

@DataJpaTest

Simple definition:

@DataJpaTest is a focused test slice for JPA components.

It usually loads:

entities
Spring Data JPA repositories
JPA-related configuration
test database configuration

It does not load the full web application.

Memory sentence:

@DataJpaTest tests the persistence layer without starting the whole app.


39. Basic @DataJpaTest

@DataJpaTest
class TaskRepositoryTest {

@Autowired
private TaskRepository taskRepository;

@Autowired
private TestEntityManager entityManager;

@Test
void findsByStatus() {
TaskEntity task = new TaskEntity("Learn JPA", "OPEN");
entityManager.persistAndFlush(task);

List<TaskEntity> result = taskRepository.findByStatus("OPEN");

assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Learn JPA");
}
}

Important:

TestEntityManager helps persist test data.
Repository method is tested against database behavior.

40. @DataJpaTest Rollback

Repository tests are commonly transactional.

After each test:

changes are rolled back

Why useful?

tests do not pollute each other
database returns to clean state

Memory sentence:

@DataJpaTest tests usually roll back after each test.


41. In-Memory DB vs Real DB

By default, many JPA slice tests use an embedded in-memory database if available.

Example:

H2

This is fast.

But there is a risk:

H2 behavior may differ from PostgreSQL/MySQL
native queries may behave differently
database-specific constraints may differ
SQL functions may differ

For serious persistence tests, use the same database type as production.

Common option:

Testcontainers with PostgreSQL

Memory sentence:

Fast H2 tests are useful, but real database tests catch real database problems.


42. Avoid Testing Spring Data Itself

Do not test that save() works.

Bad test:

@Test
void saveWorks() {
TaskEntity saved = taskRepository.save(new TaskEntity("A", "OPEN"));
assertThat(saved.getId()).isNotNull();
}

This mostly tests Spring Data/Hibernate.

Better tests:

custom query method works
mapping constraints work
relationship mapping works
pagination returns expected data
native query works
unique constraint behaves correctly

Memory sentence:

Test your mapping and queries, not Spring Data’s built-in CRUD.


43. Testing Derived Queries

Repository:

List<TaskEntity> findByClientIdAndStatus(Long clientId, String status);

Test:

@Test
void findsByClientAndStatus() {
TaskEntity openTask = new TaskEntity(10L, "Task A", "OPEN");
TaskEntity doneTask = new TaskEntity(10L, "Task B", "DONE");
TaskEntity otherClientTask = new TaskEntity(20L, "Task C", "OPEN");

entityManager.persist(openTask);
entityManager.persist(doneTask);
entityManager.persist(otherClientTask);
entityManager.flush();

List<TaskEntity> result =
taskRepository.findByClientIdAndStatus(10L, "OPEN");

assertThat(result)
.extracting(TaskEntity::getTitle)
.containsExactly("Task A");
}

44. Testing Pagination

@Test
void returnsPagedTasks() {
for (int i = 1; i <= 30; i++) {
entityManager.persist(new TaskEntity("Task " + i, "OPEN"));
}
entityManager.flush();

Pageable pageable = PageRequest.of(0, 10, Sort.by("title"));

Page<TaskEntity> page = taskRepository.findByStatus("OPEN", pageable);

assertThat(page.getContent()).hasSize(10);
assertThat(page.getTotalElements()).isEqualTo(30);
assertThat(page.getNumber()).isEqualTo(0);
}

This checks:

filtering
page size
total count
page number
sorting if asserted

45. Testing Relationships

Example:

@Test
void taskBelongsToClient() {
ClientEntity client = new ClientEntity("Client A");
entityManager.persist(client);

TaskEntity task = new TaskEntity("Task A", "OPEN", client);
entityManager.persistAndFlush(task);

TaskEntity found = taskRepository.findById(task.getId()).orElseThrow();

assertThat(found.getClient().getName()).isEqualTo("Client A");
}

This checks:

foreign key mapping
relationship mapping
persistence behavior

46. Testing Query Performance Carefully

In normal unit tests, do not overdo performance testing.

But for N+1-sensitive code, you can:

enable SQL logging
inspect query count manually
use Hibernate statistics in integration tests
test specific repository fetch methods

Simple practical approach:

write repository method with fetch join or projection
review generated SQL
test result correctness
watch SQL logs for unexpected repeated queries

Memory sentence:

For performance-sensitive queries, check generated SQL.


47. Common Persistence Bug: Missing Transaction

Bug:

public TaskDto complete(Long id) {
TaskEntity task = taskRepository.findById(id).orElseThrow();
task.complete();
return toDto(task);
}

No @Transactional.

Problem:

dirty checking may not persist change
entity may be detached
behavior depends on repository transaction boundaries

Fix:

@Transactional
public TaskDto complete(Long id) {
TaskEntity task = taskRepository.findById(id).orElseThrow();
task.complete();
return toDto(task);
}

Memory sentence:

Updates through dirty checking need a transaction.


48. Common Persistence Bug: Wrong mappedBy

Wrong:

@OneToMany(mappedBy = "client_id")
private List<TaskEntity> tasks;

Correct:

@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks;

Why?

mappedBy uses Java field name, not database column name.

49. Common Persistence Bug: Returning Entity from API

Bad:

@GetMapping("/api/tasks/{id}")
public TaskEntity getTask(@PathVariable Long id) {
return taskRepository.findById(id).orElseThrow();
}

Problems:

lazy loading during serialization
infinite recursion
sensitive fields exposed
database model becomes API contract

Fix:

@GetMapping("/api/tasks/{id}")
public TaskDto getTask(@PathVariable Long id) {
return taskService.findById(id);
}

50. Common Persistence Bug: Using EAGER Everywhere

Bad:

@ManyToOne(fetch = FetchType.EAGER)
private ClientEntity client;

@OneToMany(fetch = FetchType.EAGER)
private List<CommentEntity> comments;

Problem:

loads too much data
hard to control queries
performance problems
N+1 can still happen
large object graphs

Better:

@ManyToOne(fetch = FetchType.LAZY)
private ClientEntity client;

@OneToMany(mappedBy = "task")
private List<CommentEntity> comments = new ArrayList<>();

Fetch explicitly when needed.


51. Common Persistence Bug: Cascade ALL Everywhere

Bad:

@ManyToOne(cascade = CascadeType.ALL)
private ClientEntity client;

Problem:

operations on task can cascade to shared client
delete/update danger
unexpected persistence behavior

Better:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;

Memory sentence:

Do not cascade from many children to shared parent.


52. Common Persistence Bug: Long Transaction

Bad:

@Transactional
public void createAndUpload(CreateTaskRequest request) {
TaskEntity task = taskRepository.save(new TaskEntity(request.title()));
cloudStorage.upload(request.file());
emailService.sendTaskCreated(task.getId());
}

Problem:

database transaction remains open during slow external operations

Better:

save data in transaction
publish event or outbox record
handle upload/email outside transaction

53. Common Persistence Bug: Bulk Update Stale Entity

Bug:

@Transactional
public void completeAll(Long clientId) {
List<TaskEntity> tasks = taskRepository.findByClientId(clientId);

taskRepository.completeAllTasksForClient(clientId);

// tasks still may show old status in memory
}

Why?

bulk update bypasses managed entities already loaded in persistence context

Fix options:

avoid mixing loaded entities and bulk updates
clear persistence context after bulk update
use @Modifying(clearAutomatically = true)

54. Practical Best Practices Checklist

Use this checklist for real projects:

[ ] Controllers return DTOs, not entities.
[ ] Service layer owns transactions.
[ ] Read methods use @Transactional(readOnly = true).
[ ] Write methods use normal @Transactional.
[ ] Large list endpoints use pagination.
[ ] Repository methods use entity property names.
[ ] Long derived query names are replaced with @Query.
[ ] DTO projections are used for read-only list views.
[ ] Fetch joins or EntityGraphs are used when relationships are needed.
[ ] N+1 is checked with SQL logs.
[ ] Lazy loading is not done during JSON serialization.
[ ] Cascade is used only when lifecycle ownership is clear.
[ ] CascadeType.ALL is not used blindly.
[ ] Bulk updates are handled carefully.
[ ] Repository queries are tested with @DataJpaTest.
[ ] Production-like database tests are used for database-specific queries.
[ ] Transactions are kept short.
[ ] External calls are not made inside long database transactions.

55. Common Exam Traps

Trap 1

JPA performance depends heavily on fetching strategy.


Trap 2

Lazy does not mean “no query ever.”

Lazy means “query later when accessed.”


Trap 3

Eager does not automatically solve performance problems.

It can make them worse.


Trap 4

N+1 can happen with lazy relationships and also with poor eager fetching.


Trap 5

Fetch join loads an association with the main query.


Trap 6

@EntityGraph can define associations to fetch for a repository method.


Trap 7

DTO projections can avoid loading full entities.


Trap 8

Pagination is important for large tables.


Trap 9

Page usually needs total count.


Trap 10

Slice is lighter when total count is not needed.


Trap 11

Batch fetching groups lazy association loading.


Trap 12

Batch fetching is an optimization, not a replacement for good query design.


Trap 13

@DataJpaTest is a JPA-focused test slice.


Trap 14

Do not test Spring Data’s built-in CRUD; test your queries and mappings.


Trap 15

Always check generated SQL for important queries.


56. Real Exam Question: Over-Fetching

Question:

What is over-fetching?

Answer:

Over-fetching means loading more data than the use case needs, such as loading full entities and relationships when the API only needs a few fields.


57. Real Exam Question: Under-Fetching

Question:

What is under-fetching?

Answer:

Under-fetching means initially loading too little data and then triggering extra queries later, often causing N+1 problems.


58. Real Exam Question: Fetch Plan

Question:

What is a fetch plan?

Answer:

A fetch plan is the decision about which entities and associations should be loaded for a specific use case.


59. Real Exam Question: Fetch Join

Question:

What does a fetch join do?

Answer:

A fetch join loads the main entity and the specified association in the same query.


60. Real Exam Question: @EntityGraph

Question:

What does @EntityGraph do?

Answer:

@EntityGraph defines which associations should be fetched for a repository method.


61. Real Exam Question: Projection

Question:

What is a projection in Spring Data JPA?

Answer:

A projection is a way to return only part of the data, such as selected fields or a DTO, instead of loading and returning full entities.


62. Real Exam Question: Batch Fetching

Question:

What is batch fetching?

Answer:

Batch fetching is a Hibernate optimization that groups lazy loading of multiple entities or collections into fewer SQL queries.


63. Real Exam Question: @DataJpaTest

Question:

What is @DataJpaTest used for?

Answer:

@DataJpaTest is used to test JPA components such as entities and Spring Data repositories without loading the full application context.


64. Real Exam Question: Returning Entities

Question:

Why should REST controllers usually not return JPA entities directly?

Answer:

Because it can expose internal data, trigger lazy loading during serialization, cause N+1 queries, create infinite recursion, and couple the API to the database model.


65. Interview Answer

Question:

How do you avoid N+1 queries in Spring Data JPA?

Good answer:

I first identify N+1 by checking SQL logs or query counts. Then I choose the right fetch strategy for the use case. If I need entities and a relationship, I can use a fetch join or @EntityGraph. If the endpoint is read-only and only needs a few fields, I prefer a DTO projection. Batch fetching can also reduce the number of lazy loading queries, but it does not replace good query design.


66. Interview Answer

Question:

What is the difference between fetch join, entity graph, and projection?

Good answer:

A fetch join is a JPQL query that loads an association together with the main entity. An entity graph defines which associations should be fetched for a repository method. A projection returns only selected data, often directly into a DTO or interface, instead of loading a full entity graph. For API list views, projections are often very efficient.


67. Interview Answer

Question:

How do you test Spring Data JPA repositories?

Good answer:

I use @DataJpaTest for repository tests. It loads a focused JPA test slice with entities and repositories instead of the whole application. I test custom derived queries, @Query methods, relationship mappings, pagination, sorting, and constraints. For database-specific behavior or native queries, I prefer testing against the same database type as production, often with Testcontainers.


68. Interview Answer

Question:

What are common JPA performance mistakes?

Good answer:

Common mistakes include returning entities directly from controllers, using eager fetching everywhere, ignoring N+1 queries, loading large tables without pagination, using CascadeType.ALL blindly, relying on Open Session in View, doing external calls inside transactions, and not checking generated SQL for important queries.


69. Tiny Code Practice

Repository:

public interface TaskRepository extends JpaRepository<TaskEntity, Long> {

@Query("""
select new de.klarsync.tasks.TaskListDto(
t.id,
t.title,
t.status,
c.name
)
from TaskEntity t
join t.client c
where t.status = :status
order by t.createdAt desc
""")
Page<TaskListDto> findTaskListByStatus(
@Param("status") String status,
Pageable pageable
);
}

Questions:

  1. Does this return entities or DTOs?
  2. Does it load full TaskEntity objects?
  3. Why is this good for list endpoints?
  4. What does Pageable do?
  5. What should I check for performance?

Answers:

  1. DTOs.
  2. No, it selects fields into TaskListDto.
  3. It loads only what the endpoint needs.
  4. It applies pagination and sorting.
  5. Generated SQL and query count.

70. Tiny Bug Practice 1

Problem:

@GetMapping("/api/tasks")
public List<TaskEntity> list() {
return taskRepository.findAll();
}

Question:

What is wrong?

Answer:

It returns entities directly and loads the whole table. This can cause performance problems, lazy loading during serialization, recursion, and data leaks.

Better:

@GetMapping("/api/tasks")
public Page<TaskListDto> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return taskService.list(page, size);
}

71. Tiny Bug Practice 2

Problem:

@Transactional(readOnly = true)
public List<TaskDto> listOpenTasks() {
List<TaskEntity> tasks = taskRepository.findByStatus("OPEN");

return tasks.stream()
.map(task -> new TaskDto(
task.getId(),
task.getTitle(),
task.getClient().getName()
))
.toList();
}

Question:

What performance bug can happen?

Answer:

N+1. One query loads tasks, then one extra query may load each task’s client. Fix with fetch join, @EntityGraph, DTO projection, or batch fetching.


72. Tiny Bug Practice 3

Problem:

@ManyToOne(fetch = FetchType.EAGER)
private ClientEntity client;

@OneToMany(fetch = FetchType.EAGER)
private List<CommentEntity> comments;

Question:

Why is this risky?

Answer:

It loads related data immediately and can load too much data for many use cases. It can cause large queries, high memory usage, duplicate rows, and performance problems. Prefer lazy relationships and explicit fetch plans.


Practice Questions and Answers

Question 1

What is over-fetching?

Answer:

Over-fetching means loading more data than the use case needs.


Question 2

What is under-fetching?

Answer:

Under-fetching means loading too little data first and then triggering extra queries later, often causing N+1.


Question 3

What is a fetch plan?

Answer:

A fetch plan is the decision about which entities, fields, and associations should be loaded for a specific use case.


Question 4

What is the N+1 query problem?

Answer:

N+1 means one query loads a list, and then one additional query is executed for each item to load related data.


Question 5

How does a fetch join help?

Answer:

A fetch join loads the main entity and the needed association in the same query.


Question 6

What does @EntityGraph do?

Answer:

@EntityGraph defines which associations should be fetched for a repository method.


Question 7

What is a DTO projection?

Answer:

A DTO projection is a query that returns selected data directly into a DTO instead of loading full entities.


Question 8

What is an interface projection?

Answer:

An interface projection is a Spring Data projection interface that exposes selected getters for partial data.


Question 9

Why are projections useful for list endpoints?

Answer:

They load only the fields the endpoint needs, reduce memory usage, avoid unnecessary entity graphs, and create API-ready data.


Question 10

What is batch fetching?

Answer:

Batch fetching is a Hibernate optimization that groups lazy loading into fewer SQL queries.


Question 11

Is batch fetching a replacement for good query design?

Answer:

No. Batch fetching helps reduce query count, but explicit fetch plans and good query design are still important.


Question 12

Why should large list APIs use pagination?

Answer:

Pagination prevents loading huge tables into memory and improves response time and scalability.


Question 13

What is the difference between Page and Slice?

Answer:

Page includes total count and total pages. Slice is lighter and only tells whether there is a next slice.


Question 14

Why should I be careful with fetch join and pagination?

Answer:

Collection fetch joins can create duplicate rows and make pagination incorrect or inefficient.


Question 15

Why should I avoid returning entities from controllers?

Answer:

Returning entities can expose internal fields, trigger lazy loading during serialization, cause N+1, create infinite recursion, and couple the API to the database model.


Question 16

What is @DataJpaTest?

Answer:

@DataJpaTest is a Spring Boot test slice for testing JPA components such as entities and repositories.


Question 17

What should repository tests focus on?

Answer:

Repository tests should focus on custom queries, derived methods, mappings, relationships, constraints, pagination, sorting, and database-specific behavior.


Question 18

Why can in-memory database tests be risky?

Answer:

Because H2 or another in-memory database may behave differently from the production database, especially for native SQL, constraints, functions, and indexes.


Question 19

Why are bulk updates tricky with persistence context?

Answer:

Bulk updates bypass normal dirty checking for already managed entities and can leave the persistence context with stale data.


Question 20

What should I check for performance-sensitive JPA queries?

Answer:

Check generated SQL, query count, indexes, pagination, fetch strategy, and whether the query loads only needed data.

Final Memory Sentences

  • JPA performance depends heavily on fetching strategy.
  • Over-fetching means loading too much.
  • Under-fetching often causes N+1.
  • A fetch plan defines what data a use case needs.
  • Lazy loading can trigger SQL later.
  • Eager loading can load too much immediately.
  • Fetch join loads associations in the same query.
  • @EntityGraph defines associations to fetch.
  • DTO projection loads only selected data.
  • Interface projection exposes selected getters.
  • Pagination protects large list endpoints.
  • Page has total count; Slice is lighter.
  • Batch fetching groups lazy loads.
  • Batch fetching helps but does not replace good query design.
  • Avoid returning entities from controllers.
  • Map to DTOs inside the service layer.
  • Keep transactions short.
  • Avoid external calls inside transactions.
  • @DataJpaTest is for focused JPA repository tests.
  • Test your queries and mappings, not Spring Data CRUD itself.
  • Always check generated SQL for important queries.