Skip to main content
David Le Montagner Home Page David Le Montagner

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:

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? 😉