Skip to content

Commit 2b058d8

Browse files
committed
refactor(loan): decouple domains using Spring Events and ACL interfaces
Direct repository injection across domain boundaries prevents service extraction. This change introduces domain events and anticorruption layer interfaces as a safe intermediate step before full separation. - Add LoanCreatedEvent, LoanReturnedEvent, LoanCanceledEvent records with only primitive/serializable fields (ready for Kafka migration) - Add BookSummary and UserSummary ACL records to define the minimum contract each domain exposes to others - Add BookEventListener to handle copy restoration on loan return/cancel, removing this responsibility from LoanService - LoanService now publishes events instead of directly manipulating Book - BookRepository remains in LoanService only for atomic decrement on creation — this last coupling will be resolved in the Saga step
1 parent a0b71fc commit 2b058d8

File tree

10 files changed

+212
-16
lines changed

10 files changed

+212
-16
lines changed

.env

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ AWS_SECRET=criar-secret-key-na-aws
1111
BUCKET_NAME=library-api-s3
1212
BUCKET_REGION=sa-east-1
1313

14-
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
14+
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
15+
TRACING_SAMPLING_PROBABILITY=0.1 # 10% em prod — ajustável via env

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ AWS_SECRET=
1111
BUCKET_NAME=
1212
BUCKET_REGION=
1313

14-
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
14+
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
15+
TRACING_SAMPLING_PROBABILITY=0.1 # 10% em prod — ajustável via env
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.example.library.book;
2+
3+
import java.util.List;
4+
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.context.event.EventListener;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import com.example.library.loan.event.LoanCanceledEvent;
12+
import com.example.library.loan.event.LoanReturnedEvent;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
/**
17+
* Listener responsável por manter o estoque de cópias dos livros
18+
* em resposta a eventos do domínio Loan.
19+
*
20+
* Separação de responsabilidade:
21+
* - LoanService publica o evento e não sabe nada sobre Book
22+
* - BookEventListener reage ao evento e atualiza as cópias
23+
*
24+
* @EventListener é síncrono e transacional por padrão neste contexto:
25+
* o listener roda na mesma transação do publicador quando anotado
26+
* com @Transactional — garantindo atomicidade entre criar o empréstimo
27+
* e decrementar as cópias enquanto ainda somos monolito.
28+
*
29+
* Quando migrarmos para microservices, este listener será substituído
30+
* por um consumer Kafka/RabbitMQ sem mudança nos eventos.
31+
*/
32+
@Component
33+
@RequiredArgsConstructor
34+
public class BookEventListener {
35+
36+
private static final Logger log = LoggerFactory.getLogger(BookEventListener.class);
37+
38+
private final BookRepository bookRepository;
39+
40+
/**
41+
* Restaura as cópias disponíveis quando um empréstimo é devolvido.
42+
*/
43+
@EventListener
44+
@Transactional
45+
public void onLoanReturned(LoanReturnedEvent event) {
46+
log.info("Restoring copies on loan return: loanId={}", event.loanId());
47+
48+
List<Book> books = bookRepository.findAllById(event.bookQuantities().keySet());
49+
50+
books.forEach(book -> {
51+
Integer qty = event.bookQuantities().get(book.getId());
52+
if (qty != null) {
53+
book.setAvailableCopies(book.getAvailableCopies() + qty);
54+
log.debug("Copies restored: bookId={} title={} qty={}", book.getId(), book.getTitle(), qty);
55+
}
56+
});
57+
}
58+
59+
/**
60+
* Restaura as cópias disponíveis quando um empréstimo é cancelado.
61+
*/
62+
@EventListener
63+
@Transactional
64+
public void onLoanCanceled(LoanCanceledEvent event) {
65+
log.info("Restoring copies on loan cancel: loanId={}", event.loanId());
66+
67+
List<Book> books = bookRepository.findAllById(event.bookQuantities().keySet());
68+
69+
books.forEach(book -> {
70+
Integer qty = event.bookQuantities().get(book.getId());
71+
if (qty != null) {
72+
book.setAvailableCopies(book.getAvailableCopies() + qty);
73+
log.debug("Copies restored: bookId={} title={} qty={}", book.getId(), book.getTitle(), qty);
74+
}
75+
});
76+
}
77+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.example.library.book.dto;
2+
3+
/**
4+
* Contrato mínimo que o domínio Book expõe para outros domínios.
5+
*
6+
* Anticorruption Layer (ACL) — outros domínios nunca dependem da
7+
* entidade Book diretamente, apenas deste contrato.
8+
*
9+
* Quando Book virar um microservice, este record será preenchido
10+
* via HTTP (BookClient) em vez de consulta direta ao repositório,
11+
* sem nenhuma mudança nos consumidores.
12+
*/
13+
public record BookSummary(
14+
Long id,
15+
String title,
16+
Integer availableCopies
17+
) {}

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

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
import java.time.LocalDate;
44
import java.util.List;
5+
import java.util.Map;
6+
import java.util.stream.Collectors;
57

68
import org.slf4j.Logger;
79
import org.slf4j.LoggerFactory;
10+
import org.springframework.context.ApplicationEventPublisher;
811
import org.springframework.security.core.Authentication;
912
import org.springframework.security.core.context.SecurityContextHolder;
1013
import org.springframework.stereotype.Service;
@@ -15,6 +18,9 @@
1518
import com.example.library.book.exception.BookNotFoundException;
1619
import com.example.library.loan.dto.LoanCreateDTO;
1720
import com.example.library.loan.dto.LoanResponseDTO;
21+
import com.example.library.loan.event.LoanCanceledEvent;
22+
import com.example.library.loan.event.LoanCreatedEvent;
23+
import com.example.library.loan.event.LoanReturnedEvent;
1824
import com.example.library.loan.exception.BookNotAvailableException;
1925
import com.example.library.loan.exception.LoanAlreadyReturnedException;
2026
import com.example.library.loan.exception.LoanNotFoundException;
@@ -33,9 +39,10 @@ public class LoanService {
3339
private static final Logger log = LoggerFactory.getLogger(LoanService.class);
3440

3541
private final LoanRepository loanRepository;
36-
private final BookRepository bookRepository;
42+
private final BookRepository bookRepository; // usado apenas para verificação e decrement atômico na criação
3743
private final UserRepository userRepository;
3844
private final LoanMapper mapper;
45+
private final ApplicationEventPublisher eventPublisher;
3946

4047
// ─────────────────────────────────────────────
4148
// CRIAR EMPRÉSTIMO
@@ -46,8 +53,10 @@ public LoanResponseDTO create(LoanCreateDTO dto) {
4653

4754
User user = getAuthenticatedUser();
4855

56+
// Valida existência de todos os livros antes de iniciar
4957
for (Long bookId : dto.booksId()) {
50-
bookRepository.findById(bookId).orElseThrow(() -> new BookNotFoundException(bookId));
58+
bookRepository.findById(bookId)
59+
.orElseThrow(() -> new BookNotFoundException(bookId));
5160
}
5261

5362
log.info("Creating loan for user={} books={}", user.getEmail(), dto.booksId());
@@ -75,11 +84,17 @@ public LoanResponseDTO create(LoanCreateDTO dto) {
7584
item.setQuantity(1);
7685

7786
loan.getItems().add(item);
78-
7987
log.debug("Book added to loan: bookId={} title={}", bookId, book.getTitle());
8088
}
8189

8290
Loan saved = loanRepository.save(loan);
91+
92+
// Publica evento — outros domínios podem reagir sem acoplamento direto
93+
eventPublisher.publishEvent(new LoanCreatedEvent(
94+
saved.getId(),
95+
user.getId(),
96+
dto.booksId()
97+
));
8398

8499
log.info("Loan created: loanId={} user={} books={}",
85100
saved.getId(), user.getEmail(), dto.booksId().size());
@@ -111,12 +126,15 @@ public LoanResponseDTO returnLoan(Long loanId) {
111126
loan.setReturnDate(LocalDate.now());
112127
loan.setStatus(LoanStatus.RETURNED);
113128

114-
// Devolve as cópias — dirty checking cuida do save dentro do @Transactional
115-
loan.getItems().forEach(item -> {
116-
Book book = item.getBook();
117-
book.setAvailableCopies(book.getAvailableCopies() + item.getQuantity());
118-
log.debug("Copies restored: bookId={} title={}", book.getId(), book.getTitle());
119-
});
129+
// Monta o mapa bookId → quantidade antes de publicar o evento
130+
Map<Long, Integer> bookQuantities = buildBookQuantities(loan);
131+
132+
// Publica evento — BookEventListener restaura as cópias
133+
eventPublisher.publishEvent(new LoanReturnedEvent(
134+
loan.getId(),
135+
user.getId(),
136+
bookQuantities
137+
));
120138

121139
log.info("Loan returned: loanId={} user={}", loanId, user.getEmail());
122140

@@ -141,11 +159,14 @@ public LoanResponseDTO cancelLoan(Long loanId) {
141159

142160
loan.setStatus(LoanStatus.CANCELED);
143161

144-
// Devolve as cópias ao cancelar
145-
loan.getItems().forEach(item -> {
146-
Book book = item.getBook();
147-
book.setAvailableCopies(book.getAvailableCopies() + item.getQuantity());
148-
});
162+
Map<Long, Integer> bookQuantities = buildBookQuantities(loan);
163+
164+
// Publica evento — BookEventListener restaura as cópias
165+
eventPublisher.publishEvent(new LoanCanceledEvent(
166+
loan.getId(),
167+
user.getId(),
168+
bookQuantities
169+
));
149170

150171
log.info("Loan canceled: loanId={} user={}", loanId, user.getEmail());
151172

@@ -218,6 +239,18 @@ public List<LoanResponseDTO> findAll() {
218239
// HELPERS PRIVADOS
219240
// ─────────────────────────────────────────────
220241

242+
/**
243+
* Monta mapa bookId → quantidade a partir dos itens do empréstimo.
244+
* Usado para popular os eventos de devolução e cancelamento.
245+
*/
246+
private Map<Long, Integer> buildBookQuantities(Loan loan) {
247+
return loan.getItems().stream()
248+
.collect(Collectors.toMap(
249+
item -> item.getBook().getId(),
250+
LoanItem::getQuantity
251+
));
252+
}
253+
221254
/**
222255
* Busca um empréstimo pelo ID com itens e usuário já carregados via JOIN FETCH.
223256
* Garante que o mapper acessa as coleções LAZY dentro da transação ativa.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.example.library.loan.event;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* Evento publicado quando um empréstimo é cancelado.
7+
*
8+
* Idêntico ao LoanReturnedEvent em estrutura — separado semanticamente
9+
* pois o comportamento futuro pode divergir (ex: notificações diferentes,
10+
* métricas separadas, regras de negócio distintas).
11+
*/
12+
public record LoanCanceledEvent(
13+
Long loanId,
14+
Long userId,
15+
Map<Long, Integer> bookQuantities // bookId → quantidade a restaurar
16+
) {}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.example.library.loan.event;
2+
3+
import java.util.Set;
4+
5+
/**
6+
* Evento publicado quando um novo empréstimo é criado com sucesso.
7+
*
8+
* Carrega apenas os IDs necessários — sem referências a entidades JPA.
9+
* Isso garante que o evento seja serializável quando migrarmos para
10+
* mensageria (Kafka/RabbitMQ) sem nenhuma mudança no contrato.
11+
*/
12+
public record LoanCreatedEvent(
13+
Long loanId,
14+
Long userId,
15+
Set<Long> bookIds
16+
) {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.example.library.loan.event;
2+
3+
import java.util.Map;
4+
5+
/**
6+
* Evento publicado quando um empréstimo é devolvido.
7+
*
8+
* Carrega um mapa de bookId → quantidade devolvida, permitindo que
9+
* o Book domain restaure as cópias disponíveis de forma precisa.
10+
*/
11+
public record LoanReturnedEvent(
12+
Long loanId,
13+
Long userId,
14+
Map<Long, Integer> bookQuantities // bookId → quantidade a restaurar
15+
) {}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.example.library.user.dto;
2+
3+
/**
4+
* Contrato mínimo que o domínio User expõe para outros domínios.
5+
*
6+
* Anticorruption Layer (ACL) — outros domínios nunca dependem da
7+
* entidade User diretamente, apenas deste contrato.
8+
*
9+
* Quando User/Auth virar um microservice, este record será preenchido
10+
* via claims do JWT ou via HTTP (UserClient), sem mudança nos consumidores.
11+
*/
12+
public record UserSummary(
13+
Long id,
14+
String name,
15+
String email
16+
) {}

src/test/java/com/example/library/services/LoanServiceTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.mockito.InjectMocks;
1515
import org.mockito.Mock;
1616
import org.mockito.junit.jupiter.MockitoExtension;
17+
import org.springframework.context.ApplicationEventPublisher;
1718
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
1819
import org.springframework.security.core.Authentication;
1920
import org.springframework.security.core.context.SecurityContext;
@@ -65,6 +66,9 @@ class LoanServiceTest {
6566

6667
@Mock
6768
private SecurityContext securityContext;
69+
70+
@Mock
71+
private ApplicationEventPublisher eventPublisher;
6872

6973
@InjectMocks
7074
private LoanService loanService;

0 commit comments

Comments
 (0)