Skip to content

Commit c759fcb

Browse files
committed
perf(loan): change FetchType from EAGER to LAZY on Loan items and user
EAGER loading on collections always fetches all loan items regardless of whether they are needed, causing unnecessary database queries and potential N+1 problems at scale. - Changed OneToMany(items) and ManyToOne(user) to FetchType.LAZY - Added JOIN FETCH queries in LoanRepository for cases that need items - Introduced findWithItemsOrThrow() helper in LoanService to centralize the fetch strategy and avoid LazyInitializationException - findAll() and findByUser() now use dedicated JOIN FETCH queries - findOverdue() and markOverdue() remain lightweight (no items needed)
1 parent f53fe22 commit c759fcb

File tree

4 files changed

+121
-65
lines changed

4 files changed

+121
-65
lines changed

src/main/java/com/example/library/loan/Loan.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public class Loan extends BaseEntity {
5151
@Enumerated(EnumType.STRING)
5252
private LoanStatus status;
5353

54-
@ManyToOne
54+
@ManyToOne(fetch = FetchType.LAZY)
5555
@JoinColumn(name = "user_id", nullable = false)
5656
private User user;
5757

src/main/java/com/example/library/loan/LoanRepository.java

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,69 @@
22

33
import java.time.LocalDate;
44
import java.util.List;
5+
import java.util.Optional;
56

67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.Query;
89
import org.springframework.data.repository.query.Param;
910

1011
public interface LoanRepository extends JpaRepository<Loan, Long> {
12+
13+
/**
14+
* Busca um empréstimo pelo ID carregando itens e usuário via JOIN FETCH.
15+
* Necessário após migração de EAGER para LAZY para evitar LazyInitializationException
16+
* ao acessar items e user fora da sessão JPA.
17+
*/
18+
@Query("""
19+
SELECT l FROM Loan l
20+
JOIN FETCH l.items i
21+
JOIN FETCH i.book
22+
JOIN FETCH l.user
23+
WHERE l.id = :id
24+
""")
25+
Optional<Loan> findByIdWithItemsAndUser(@Param("id") Long id);
26+
27+
/**
28+
* Todos os empréstimos de um usuário específico, com itens e livros carregados.
29+
*/
30+
@Query("""
31+
SELECT DISTINCT l FROM Loan l
32+
JOIN FETCH l.items i
33+
JOIN FETCH i.book
34+
WHERE l.user.id = :userId
35+
""")
36+
List<Loan> findByUserIdWithItems(@Param("userId") Long userId);
1137

12-
/**
13-
* Todos os empréstimos de um usuário específico.
14-
*/
15-
List<Loan> findByUserId(Long userId);
38+
/**
39+
* Empréstimos vencidos: status WAITING_RETURN e dueDate antes de hoje.
40+
* Não precisa de itens — apenas marca o status.
41+
*/
42+
@Query("""
43+
SELECT l FROM Loan l
44+
WHERE l.status = com.example.library.loan.LoanStatus.WAITING_RETURN
45+
AND l.dueDate < :today
46+
""")
47+
List<Loan> findOverdueLoans(@Param("today") LocalDate today);
48+
49+
/**
50+
* Todos os empréstimos com itens e livros carregados — uso exclusivo de ADMIN.
51+
*/
52+
@Query("""
53+
SELECT DISTINCT l FROM Loan l
54+
JOIN FETCH l.items i
55+
JOIN FETCH i.book
56+
JOIN FETCH l.user
57+
""")
58+
List<Loan> findAllWithItems();
1659

17-
/**
18-
* Empréstimos vencidos: status WAITING_RETURN e dueDate antes de hoje.
19-
*/
20-
@Query("""
21-
SELECT l FROM Loan l
22-
WHERE l.status = com.example.library.loan.LoanStatus.WAITING_RETURN
23-
AND l.dueDate < :today
24-
""")
25-
List<Loan> findOverdueLoans(@Param("today") LocalDate today);
26-
27-
/**
28-
* Empréstimos ativos de um usuário (para verificar limite de empréstimos).
29-
*/
30-
@Query("""
31-
SELECT COUNT(l) FROM Loan l
32-
WHERE l.user.id = :userId
33-
AND l.status = com.example.library.loan.LoanStatus.WAITING_RETURN
34-
""")
35-
long countActiveByUserId(@Param("userId") Long userId);
60+
/**
61+
* Empréstimos ativos de um usuário (para verificar limite de empréstimos).
62+
* Não precisa de itens — apenas conta.
63+
*/
64+
@Query("""
65+
SELECT COUNT(l) FROM Loan l
66+
WHERE l.user.id = :userId
67+
AND l.status = com.example.library.loan.LoanStatus.WAITING_RETURN
68+
""")
69+
long countActiveByUserId(@Param("userId") Long userId);
3670
}

src/main/java/com/example/library/loan/LoanService.java

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
import com.example.library.user.UserRepository;
2525
import com.example.library.user.exception.UserNotFoundException;
2626

27+
import lombok.RequiredArgsConstructor;
28+
29+
@RequiredArgsConstructor
2730
@Service
2831
public class LoanService {
2932

@@ -34,13 +37,6 @@ public class LoanService {
3437
private final UserRepository userRepository;
3538
private final LoanMapper mapper;
3639

37-
public LoanService(LoanRepository loanRepository, BookRepository bookRepository, UserRepository userRepository, LoanMapper mapper) {
38-
this.loanRepository = loanRepository;
39-
this.bookRepository = bookRepository;
40-
this.userRepository = userRepository;
41-
this.mapper = mapper;
42-
}
43-
4440
// ─────────────────────────────────────────────
4541
// CRIAR EMPRÉSTIMO
4642
// ─────────────────────────────────────────────
@@ -63,7 +59,6 @@ public LoanResponseDTO create(LoanCreateDTO dto) {
6359
loan.setStatus(LoanStatus.WAITING_RETURN);
6460

6561
for (Long bookId : dto.booksId()) {
66-
6762
Book book = bookRepository.findById(bookId)
6863
.orElseThrow(() -> new BookNotFoundException(bookId));
6964

@@ -89,7 +84,8 @@ public LoanResponseDTO create(LoanCreateDTO dto) {
8984
log.info("Loan created: loanId={} user={} books={}",
9085
saved.getId(), user.getEmail(), dto.booksId().size());
9186

92-
return mapper.toDTO(loan);
87+
// Recarrega com JOIN FETCH para garantir que o mapper acessa itens dentro da transação
88+
return mapper.toDTO(findWithItemsOrThrow(saved.getId()));
9389
}
9490

9591
// ─────────────────────────────────────────────
@@ -100,7 +96,7 @@ public LoanResponseDTO create(LoanCreateDTO dto) {
10096
public LoanResponseDTO returnLoan(Long loanId) {
10197

10298
User user = getAuthenticatedUser();
103-
Loan loan = find(loanId);
99+
Loan loan = findWithItemsOrThrow(loanId);
104100

105101
validateOwnershipOrAdmin(loan, user);
106102

@@ -135,7 +131,7 @@ public LoanResponseDTO returnLoan(Long loanId) {
135131
public LoanResponseDTO cancelLoan(Long loanId) {
136132

137133
User user = getAuthenticatedUser();
138-
Loan loan = find(loanId);
134+
Loan loan = findWithItemsOrThrow(loanId);
139135

140136
validateOwnershipOrAdmin(loan, user);
141137

@@ -177,32 +173,26 @@ public void markOverdue() {
177173
@Transactional(readOnly = true)
178174
public LoanResponseDTO findById(Long loanId) {
179175
User user = getAuthenticatedUser();
180-
Loan loan = find(loanId);
181-
176+
Loan loan = findWithItemsOrThrow(loanId);
182177
validateOwnershipOrAdmin(loan, user);
183178
return mapper.toDTO(loan);
184179
}
185180

186181
@Transactional(readOnly = true)
187182
public List<LoanResponseDTO> findMyLoans() {
188-
189183
User user = getAuthenticatedUser();
190-
191184
log.debug("Fetching loans for user={}", user.getEmail());
192-
193-
return loanRepository.findByUserId(user.getId())
185+
return loanRepository.findByUserIdWithItems(user.getId())
194186
.stream()
195187
.map(mapper::toDTO)
196188
.toList();
197189
}
198190

199191
@Transactional(readOnly = true)
200192
public List<LoanResponseDTO> findByUser(Long userId) {
201-
202193
userRepository.findById(userId)
203194
.orElseThrow(() -> new UserNotFoundException(userId));
204-
205-
return loanRepository.findByUserId(userId)
195+
return loanRepository.findByUserIdWithItems(userId)
206196
.stream()
207197
.map(mapper::toDTO)
208198
.toList();
@@ -218,7 +208,7 @@ public List<LoanResponseDTO> findOverdue() {
218208

219209
@Transactional(readOnly = true)
220210
public List<LoanResponseDTO> findAll() {
221-
return loanRepository.findAll()
211+
return loanRepository.findAllWithItems()
222212
.stream()
223213
.map(mapper::toDTO)
224214
.toList();
@@ -228,10 +218,14 @@ public List<LoanResponseDTO> findAll() {
228218
// HELPERS PRIVADOS
229219
// ─────────────────────────────────────────────
230220

231-
private Loan find(Long loanId) {
232-
return loanRepository.findById(loanId)
233-
.orElseThrow(() -> new LoanNotFoundException(loanId));
234-
}
221+
/**
222+
* Busca um empréstimo pelo ID com itens e usuário já carregados via JOIN FETCH.
223+
* Garante que o mapper acessa as coleções LAZY dentro da transação ativa.
224+
*/
225+
private Loan findWithItemsOrThrow(Long loanId) {
226+
return loanRepository.findByIdWithItemsAndUser(loanId)
227+
.orElseThrow(() -> new LoanNotFoundException(loanId));
228+
}
235229

236230
/**
237231
* Recupera o usuário autenticado direto do SecurityContext.
@@ -245,14 +239,15 @@ private User getAuthenticatedUser() {
245239

246240
/**
247241
* Garante que apenas o dono do empréstimo ou um ADMIN pode operá-lo.
242+
* Retorna 404 intencionalmente para não vazar que o empréstimo existe.
248243
*/
249244
private void validateOwnershipOrAdmin(Loan loan, User user) {
250245
boolean isAdmin = user.getRoles().contains("ROLE_ADMIN");
251246
boolean isOwner = loan.getUser().getId().equals(user.getId());
252247

253248
if (!isOwner && !isAdmin) {
254249
log.warn("Unauthorized loan access attempt: loanId={} userId={}", loan.getId(), user.getId());
255-
throw new LoanUnauthorizedException(loan.getId()); // 404 intencional — não vazar que o loan existe
250+
throw new LoanUnauthorizedException(loan.getId());
256251
}
257252
}
258253
}

0 commit comments

Comments
 (0)