Transactions et architecture hexagonale avec Spring Boot
Introduction #
Dans l'article du mois dernier, je parle de lâarchitecture hexagonale et du talk qui mâa paru le plus intĂ©ressant sur le sujet. Toutefois, un des problĂšmes rĂ©currents au sein des applications nâest pas abordĂ© dans ce talk: la gestion transactionnelle dâun ensemble dâopĂ©rations.
Dans cet article, je vais vous proposer une solution possible pour assurer la rĂ©alisation de plusieurs opĂ©rations BDD de façon transactionnelle tout en respectant les fondamentaux de lâarchitecture hexagonale câest Ă dire de ne pas intĂ©grer dâĂ©lĂ©ments de framework dans notre domaine mĂ©tier.
Le cas dâusage #
Pour illustrer mes propos, je vais utiliser un cas dâusage classique qui est le transfert dâargent entre deux comptes bancaires. Lâargent va dâabord ĂȘtre retirĂ© du compte A puis sera ensuite ajoutĂ© au compte B. Les deux opĂ©rations doivent ĂȘtre rĂ©alisĂ©es avec succĂšs pour que la transaction soit commitĂ©e. Si une erreur survient pendant la transaction, elle doit ĂȘtre rollbackĂ©e pour assurer lâintĂ©gritĂ© des donnĂ©es.
Le domaine & lâinfrastructure #
Lâobjet de cet article nâest pas de refaire une explication de lâarchitecture hexagonale:
- Je nâirai donc pas dans le dĂ©tail de lâimplĂ©mentation du domaine et de lâinfrastructure mais vous pouvez vous y plonger en rĂ©cupĂ©rant le code source complet. Si vous cherchez Ă comprendre les bases de lâarchitecture hexagonale avec un exemple concret dâimplĂ©mentation utilisant Spring Boot, je vous invite encore une fois Ă regarder cet excellent talk sur YouTube.
- Jâai choisi de simplifier le domaine au maximum: on pourrait, par exemple, imaginer lâusage de value objects pour les montants de façon Ă gĂ©rer la devise. Mais ça ne sera pas le cas dans cet exemple đ
Simuler une erreur et tester le rollback #
Avant de nous lancer dans lâimplĂ©mentation de la gestion transactionnelle, nous allons prĂ©parer le test qui nous permettra de valider que le rollback a bien eu lieu.
Pour ce faire, nous allons mocker notre repository pour dĂ©clencher une erreur (RuntimeException) lors de la sauvegarde. Nous allons Ă©galement prĂ©ciser que nous voulons appliquer ce mock uniquement si le compte concernĂ© par la sauvegarde est le compte de destination. Nous pourrons ainsi contrĂŽler que le rollback a eu lieu en vĂ©rifiant que le compte source, pour lequel le save a eu lieu sans erreur, a gardĂ© sa balance dâorigine.
@Test
public void whenPostTransfer_andError_thenStatus500_andProblemDetail_andRollback() throws Exception {
Account sourceAccount = accountManager.openAccount(BigDecimal.valueOf(2000));
Account destinationAccount = accountManager.openAccount(BigDecimal.valueOf(1000));
// Simulate a RuntimeException while saving destination account
when(accountsRepository.save(
argThat(new AccountIdArgumentMatcher(destinationAccount))))
.thenThrow(new RuntimeException());
// Assert that response is a ProblemDetail
mockMvc.perform(post("/transfer")
.contentType(MediaType.APPLICATION_JSON)
.content("{ \"amount\": \"500\"," +
" \"source\": \""+sourceAccount.id()+"\"," +
" \"destination\": \""+destinationAccount.id()+"\" }"))
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(status().is5xxServerError());
// Assert that transaction has been rolled back
assertThat(accountManager.viewAccount(sourceAccount.id()).balance())
.isEqualByComparingTo(sourceAccount.balance());
}
Pour ĂȘtre capable dâidentifier le compte concernĂ© par le mock, nous utilisons un ArgumentMatcher:
public class AccountIdArgumentMatcher implements ArgumentMatcher<Account> {
private Account left;
public AccountIdArgumentMatcher(Account left) {
this.left = left;
}
@Override
public boolean matches(Account right) {
return left.id().equals(right.id());
}
}
Implémenter la gestion transactionnelle #
Nous voici maintenant au coeur du problĂšme: la mise en place de la transaction.
Dans lâidĂ©al, nous aurions voulu positionner lâannotation @Transactional au niveau de la mĂ©thode transferMoney de la classe AccountService. NĂ©anmoins, cette classe se situe dans la couche domaine. Pour respecter les principes de lâarchitecture hexagonale, nous ne voulons pas insĂ©rer dâĂ©lĂ©ments de framework dans cette couche.
Si vous avez suivi le talk dont je parle plus haut, vous vous souvenez de lâutilisation astucieuse de @ComponentScan pour injecter le @DomainService dans notre partie infrastructure. Est-ce quâil existe une astuce du mĂȘme type qui nous permettrait dâinsĂ©rer lâannotation @Transactional au niveau de lâannotation @DomainService via le code de notre couche infrastructure? La rĂ©ponse est oui: grace Ă AspectJ.
Pour commencer, nous devons Ă©crire le code qui va permettre lâexĂ©cution au sein dâune transaction:
package dlemontagner.moneytransfer.infrastructure.configuration.transactional;
import org.springframework.transaction.annotation.Transactional;
import java.util.function.Supplier;
public class TransactionalExecutor {
@Transactional
<T> T executeInTransaction(Supplier<T> execution) {
return execution.get();
}
}
Ensuite, nous devons âcapturerâ les appels aux mĂ©thodes contenues dans les classes annotĂ©es @DomainService pour les exĂ©cuter via le TransactionalExecutor dĂ©fini ci-dessus:
package dlemontagner.moneytransfer.infrastructure.configuration.transactional;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
@Aspect
public class TransactionalDomainServiceAspect {
private final TransactionalExecutor transactionalExecutor;
public TransactionalDomainServiceAspect(TransactionalExecutor transactionalExecutor) {
this.transactionalExecutor = transactionalExecutor;
}
@Pointcut("@within(ddd.DomainService)")
private void withinDomainService() {
}
@Around("withinDomainService()")
private Object domainService(ProceedingJoinPoint proceedingJoinPoint) {
return transactionalExecutor.executeInTransaction(() -> {
try {
return proceedingJoinPoint.proceed();
} catch (Throwable e) {
throw new RuntimeException(e);
}
});
}
}
A noter que, si vous le souhaitez, il est possible dâutiliser TransactionTemplate au lieu de notre TransactionalExecutor.
Pour terminer, nous devons crĂ©er la configuration qui permettra dâactiver TransactionalDomainServiceAspect & TransactionalExecutor au sein de notre infrastructure:
package dlemontagner.moneytransfer.infrastructure.configuration.transactional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
@Configuration
@EnableAspectJAutoProxy
public class TransactionalConfiguration {
@Bean
TransactionalDomainServiceAspect transactionalDomainServiceAspect(TransactionalExecutor transactionalExecutor) {
return new TransactionalDomainServiceAspect(transactionalExecutor);
}
@Bean
TransactionalExecutor transactionalExecutor() {
return new TransactionalExecutor();
}
}
Suite Ă cela, notre test whenPostTransfer_andError_thenStatus500_andProblemDetail_andRollback doit passer au vert â Â đ
Le code source #
Retrouvez l'intégralité du code source dans ce repository.
Conclusion #
Comme nous pouvons le constater, garder une couche domaine sans dĂ©pendance au framework technique constitue une contrainte importante et quâil ne faut pas nĂ©gliger lorsque lâon choisit dâadopter une architecture hexagonale. Pour la respecter, il faudra parfois faire preuve dâastuce et ceci peut compliquer le dĂ©veloppement initial ainsi que lâapprĂ©hension du code par les nouveaux venus sur le projet.
AspectJ nous a permis de rĂ©pondre Ă notre problĂ©matique de transactions mais je pense que cette solution est Ă garder en tĂȘte pour dâautres situations nĂ©cessitant dâenglober, via la couche infrastructure, des implĂ©mentations techniques autour dâĂ©lĂ©ments de la couche domaine.
Enfin, pour faire le lien avec lâarticle du mois dernier, vous vous demandez peut ĂȘtre si cette mĂ©thode est applicable Ă Quarkus. AspectJ nâest pas utilisĂ© par Quarkus, il faudra donc se tourner vers dâautres solutions. Je nâai pas encore creusĂ© le sujet mais il est probable que lâĂ©criture dâune extension soit nĂ©cessaire comme ce fut le cas pour lâinjection des @DomainService. Peut-ĂȘtre lâobjet dâun prochain article? đ