Transacciones Declarativas en Arquitecturas Hexagonales con Spring WebFlux y AOP
- Mauricio ECR
- Snippets
- 27 Aug, 2025
En el desarrollo de aplicaciones reactivas modernas, especialmente bajo paradigmas como la Arquitectura Hexagonal, surgen desafíos que nos obligan a repensar cómo aplicamos conceptos transversales. Uno de los interrogantes más comunes es: ¿dónde y cómo gestionamos las transacciones de base de datos sin contaminar nuestra lógica de negocio? Este artículo documenta un viaje desde esa pregunta inicial hasta una solución robusta y elegante, utilizando el poder de la Programación Orientada a Aspectos (AOP) en un entorno Spring WebFlux con R2DBC.
La Arquitectura como Punto de Partida
Antes de sumergirnos en el código, es fundamental visualizar la estructura del proyecto. Una organización clara de paquetes, que refleje las capas de la Arquitectura Hexagonal, es la base sobre la que construiremos nuestra solución. El dominio permanece en el centro, puro y sin dependencias externas, mientras que la aplicación y la infraestructura se organizan a su alrededor.
ms_auth/
├── applications/app-service/ # Módulo principal de la aplicación Spring Boot
│ ├── build.gradle
│ └── src/
│ ├── main/java/com/app247/
│ │ ├── MainApplication.java
│ │ └── config/aop/
│ │ └── TransactionalUseCaseAspect.java # Nuestro Aspecto AOP
│ └── test/java/com/app247/config/aop/
│ ├── TransactionalUseCaseAspectTest.java # Test unitario del Aspecto
│ └── TransactionalRollbackSelfContainedTest.java # Test de Integración
│
├── domain/
│ ├── model/
│ └── usecase/ # Módulo de la lógica de negocio pura
│ └── src/main/java/com/app247/usecase/shared/core/usecase/
│ ├── TransactionalWrapperUseCase.java # Anotación personalizada
│ └── UseCase.java # Interfaz genérica
│
└── infrastructure/
├── r2dbc-postgresql/ # Módulo adaptador para la base de datos
└── reactive-web/ # Módulo adaptador para los controladores REST
El Dilema Inicial: La Transacción y la Unidad de Trabajo
Todo comienza con una necesidad fundamental: asegurar la atomicidad de las operaciones. Imaginemos un caso de uso de negocio, como procesar una compra, que implica modificar el inventario de productos y crear un registro de orden. Ambas acciones deben tener éxito, o ninguna debe persistir. Esta es la definición de una unidad de trabajo, y la herramienta para garantizarla es la transacción.
La primera intuición podría ser colocar la anotación @Transactional de Spring en los métodos del repositorio. Sin embargo, esto es incorrecto. Una transacción en el repositorio solo cubriría una única operación de base de datos, rompiendo la unidad de trabajo del negocio. La transacción debe envolver la ejecución completa del caso de uso.
Esto nos lleva a la capa de servicio o caso de uso. Pero aquí nos encontramos con el primer gran obstáculo arquitectónico. En una Arquitectura Hexagonal, la capa de dominio (donde residen los casos de uso) debe ser pura. No puede, ni debe, tener dependencias de frameworks externos como Spring. Anotar un caso de uso del dominio con @Transactional viola este principio fundamental, acoplando nuestra lógica de negocio más preciada a un detalle de infraestructura.
La Solución Emerge: Programación Orientada a Aspectos
Si no podemos modificar el dominio, debemos aplicar el comportamiento transaccional desde afuera, de una manera no invasiva. Aquí es donde la Programación Orientada a Aspectos (AOP) brilla. AOP nos permite interceptar la ejecución de nuestros métodos para añadir funcionalidades transversales (como transacciones, seguridad o logging) sin alterar el código original.
La estrategia que emerge es crear un mecanismo declarativo y reutilizable que nos permita “marcar” qué casos de uso deben ser transaccionales, dejando que la magia de AOP haga el resto.
Una Anotación para Declarar la Intención
El primer paso es crear una anotación personalizada. Su único propósito es servir como una señal o marcador. Al ser parte de nuestro código de dominio (usecase), no introduce una dependencia directa de Spring, sino que define un contrato interno.
package com.app247.usecase.shared.core.usecase;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Anotación para marcar clases de Casos de Uso que deben ser
* envueltas en una transacción reactiva de forma automática.
*/
@Target(ElementType.TYPE) // Se aplica a nivel de clase
@Retention(RetentionPolicy.RUNTIME) // Disponible en tiempo de ejecución para que Spring la lea
public @interface TransactionalWrapperUseCase {
}
Junto a esta, podemos definir una interfaz genérica para estandarizar nuestros casos de uso, promoviendo un diseño limpio y consistente.
package com.app247.usecase.shared.core.usecase;
// Interfaz genérica (opcional pero recomendada)
public interface UseCase<Request, Response> {
Response execute(Request request);
}
El Aspecto: El Motor de la Transacción
Con la anotación en su lugar, construimos el componente que buscará esta marca y aplicará la lógica transaccional. Este es nuestro Aspecto, una clase de infraestructura que vive en la capa de aplicación.
Este Aspecto tiene dos partes clave:
- Pointcut: Una expresión que actúa como un selector. Le dice a Spring: “Encuentra todos los métodos públicos en cualquier clase que esté anotada con
@TransactionalWrapperUseCase”. - Advice: La lógica que se ejecuta cuando el Pointcut encuentra una coincidencia. Usaremos un
advicede tipo@Around, que nos permite envolver completamente la ejecución del método original.
La lógica del advice es simple pero poderosa: toma el Mono o Flux devuelto por el caso de uso y lo compone con el TransactionalOperator reactivo de Spring. Este operador se encarga de iniciar la transacción antes de la suscripción y de realizar commit o rollback al finalizar.
package com.app247.config.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@Aspect
@Component
public class TransactionalUseCaseAspect {
private final TransactionalOperator transactionalOperator;
public TransactionalUseCaseAspect(TransactionalOperator transactionalOperator) {
this.transactionalOperator = transactionalOperator;
}
@Pointcut("@within(com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase) && execution(public * *(..))")
public void transactionalUseCase() {
// Método vacío para nombrar el pointcut.
}
@Around("transactionalUseCase()")
public Object wrapInTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof Mono) {
return ((Mono<?>) result).as(transactionalOperator::transactional);
} else if (result instanceof Flux) {
return ((Flux<?>) result).as(transactionalOperator::transactional);
}
return result;
}
}
Con estos dos elementos, hemos creado un sistema donde simplemente anotando una clase de caso de uso con @TransactionalWrapperUseCase, garantizamos que su ejecución será atómica, sin haber escrito una sola línea de código transaccional dentro del propio caso de uso.
Probando la Solución: De la Confianza a la Certeza
Una solución no está completa hasta que se prueba rigurosamente. Para este mecanismo, necesitamos dos niveles de prueba para tener una confianza total.
Nivel 1: El Test de Cableado (Unitario)
El primer test debe responder a la pregunta: ¿Nuestro aspecto AOP está correctamente configurado para interceptar la llamada y usar el TransactionalOperator? Este test valida tanto respuestas Mono como Flux.
Este test no necesita una base de datos. Utiliza un contexto de Spring para activar el mecanismo AOP, pero reemplaza todas las dependencias externas (TransactionalOperator, repositorios) con Mocks. El objetivo no es probar el rollback, sino verificar la interacción: que el método transactional() del operador sea invocado.
package com.app247.config.aop;
import com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.reactive.TransactionalOperator;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SpringBootTest(classes = TransactionalUseCaseAspectTest.TestConfig.class)
class TransactionalUseCaseAspectTest {
@Autowired
private PurchaseProductUseCasePort purchaseUseCase;
@Autowired
private FindProductsUseCasePort findProductsUseCase; // Caso de uso que devuelve Flux
@Autowired
private NonReactiveUseCasePort nonReactiveUseCase;
@MockitoBean
private ProductRepository productRepository;
@MockitoBean
private OrderRepository orderRepository;
@MockitoBean
private TransactionalOperator transactionalOperator;
@InjectMocks
private TransactionalUseCaseAspect transactionalUseCaseAspect;
@Test
void whenUseCaseReturnsMono_thenItShouldBeWrappedInTransaction() {
// ARRANGE
Product fakeProduct = new Product("prod-123", 10);
Order fakeOrder = new Order("user-007", "prod-123");
when(productRepository.findById(any())).thenReturn(Mono.just(fakeProduct));
when(orderRepository.save(any())).thenReturn(Mono.just(fakeOrder));
when(productRepository.updateStock(any(), any(Integer.class))).thenReturn(Mono.empty());
when(transactionalOperator.transactional(any(Mono.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// ACT
Mono<Order> result = purchaseUseCase.execute("user-007", "prod-123");
// ASSERT
StepVerifier.create(result).expectNext(fakeOrder).verifyComplete();
verify(transactionalOperator).transactional(any(Mono.class));
verify(productRepository).updateStock("prod-123", 9);
}
@Test
void whenUseCaseReturnsFlux_thenItShouldBeWrappedInTransaction() {
// ARRANGE
Product fakeProduct1 = new Product("prod-001", 5);
Product fakeProduct2 = new Product("prod-002", 3);
when(productRepository.findAll()).thenReturn(Flux.just(fakeProduct1, fakeProduct2));
// Configuramos el mock para que el operador transaccional simplemente devuelva el Flux original
when(transactionalOperator.transactional(any(Flux.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// ACT
Flux<Product> result = findProductsUseCase.execute(null); // `null` porque no requiere parámetros
// ASSERT
StepVerifier.create(result)
.expectNext(fakeProduct1)
.expectNext(fakeProduct2)
.verifyComplete();
// La verificación clave: ¿Se llamó al operador con un Flux?
verify(transactionalOperator).transactional(any(Flux.class));
}
/**
* Test para el caso no reactivo.
*/
@Test
void whenUseCaseIsNotReactive_thenItShouldNotBeWrappedInTransaction() {
// --- ARRANGE (Preparar) ---
String expectedResult = "Este es un resultado síncrono";
// --- ACT (Actuar) ---
// Ejecutamos el caso de uso que devuelve un String simple.
String actualResult = nonReactiveUseCase.execute(null);
// --- ASSERT (Verificar) ---
// 1. Verificamos que el resultado devuelto es el original, sin cambios.
assertThat(actualResult).isEqualTo(expectedResult);
// 2. La verificación MÁS IMPORTANTE: nos aseguramos de que el operador transaccional
// NUNCA fue invocado, ya que la respuesta no era ni Mono ni Flux.
verify(transactionalOperator, never()).transactional(any(Mono.class));
verify(transactionalOperator, never()).transactional(any(Flux.class));
}
@Test
void transactionalUseCasePointcut_shouldExecuteForCoverage() {
// --- ACT ---
// Simplemente llamamos al método vacío.
// La herramienta de cobertura registrará que se ha entrado en este método.
// --- ASSERT ---
// Como el método no hace nada, la única aserción posible es
// que la llamada no lance ninguna excepción.
assertDoesNotThrow(() -> {
transactionalUseCaseAspect.transactionalUseCase();
});
}
@Configuration
@EnableAspectJAutoProxy
@Import(TransactionalUseCaseAspect.class)
static class TestConfig {
@Bean
public PurchaseProductUseCasePort purchaseProductUseCase(ProductRepository productRepo, OrderRepository orderRepo) {
return new PurchaseProductUseCase(productRepo, orderRepo);
}
@Bean
public FindProductsUseCasePort findProductsUseCase(ProductRepository productRepo) {
return new FindProductsUseCase(productRepo);
}
@Bean
public NonReactiveUseCasePort nonReactiveUseCase() {
return new NonReactiveUseCase();
}
}
// --- Definiciones Fakes ---
record Product(String id, int stock) {}
record Order(String userId, String productId) {}
interface ProductRepository {
Mono<Product> findById(String productId);
Flux<Product> findAll(); // Añadido para el test de Flux
Mono<Void> updateStock(String productId, int newStock);
}
interface OrderRepository { Mono<Order> save(Order order); }
interface PurchaseProductUseCasePort { Mono<Order> execute(String userId, String productId); }
interface FindProductsUseCasePort { Flux<Product> execute(Void request); } // Nuevo caso de uso para Flux
interface NonReactiveUseCasePort { String execute(Void request); }
@TransactionalWrapperUseCase
static class PurchaseProductUseCase implements PurchaseProductUseCasePort {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public PurchaseProductUseCase(ProductRepository p, OrderRepository o) { this.productRepository = p; this.orderRepository = o; }
public Mono<Order> execute(String userId, String productId) {
return productRepository.findById(productId)
.flatMap(product -> productRepository.updateStock(product.id(), product.stock() - 1)
.then(orderRepository.save(new Order(userId, productId))));
}
}
@TransactionalWrapperUseCase
static class FindProductsUseCase implements FindProductsUseCasePort {
private final ProductRepository productRepository;
public FindProductsUseCase(ProductRepository p) { this.productRepository = p; }
public Flux<Product> execute(Void request) {
return productRepository.findAll();
}
}
@TransactionalWrapperUseCase
static class NonReactiveUseCase implements NonReactiveUseCasePort {
@Override
public String execute(Void request) {
return "Este es un resultado síncrono";
}
}
}
Nivel 2: El Test de Comportamiento (Integración)
El segundo test debe responder a una pregunta más importante: si una operación falla, ¿la transacción realmente hace rollback?
Para esto, necesitamos un test de integración que utilice una base de datos real (en memoria, como H2, para velocidad y aislamiento) y el TransactionalOperator real de Spring. La clave aquí es usar @SpyBean para envolver un repositorio real y forzar un fallo en una de sus operaciones. La validación final consiste en consultar la base de datos después del fallo y verificar que el estado de los datos ha sido revertido a su estado original.
Este test es completamente autocontenido: define su propia configuración, su esquema de base de datos y sus implementaciones de dominio e infraestructura, pero lo más importante es que importa y prueba el Aspecto de AOP de producción real.
package com.app247.config.aop;
import com.app247.usecase.shared.core.usecase.TransactionalWrapperUseCase;
import io.r2dbc.spi.ConnectionFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.r2dbc.R2dbcAutoConfiguration;
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.data.annotation.Id;
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.r2dbc.connection.R2dbcTransactionManager;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Repository;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.springframework.data.relational.core.query.Criteria.where;
import static org.springframework.data.relational.core.query.Query.query;
@SpringBootTest(classes = TransactionalRollbackSelfContainedTest.TestConfig.class)
@ImportAutoConfiguration({
R2dbcAutoConfiguration.class,
TransactionAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class
})
@TestPropertySource(properties = {
"spring.r2dbc.url=r2dbc:h2:mem:///finaltestdb;DB_CLOSE_DELAY=-1;",
"spring.r2dbc.username=sa",
"spring.r2dbc.password=",
"spring.sql.init.mode=never"
})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TransactionalRollbackSelfContainedTest {
@Autowired private PurchaseProductUseCasePort purchaseUseCase;
@Autowired private DatabaseClient databaseClient;
@Autowired private R2dbcEntityTemplate template;
@MockitoSpyBean
private OrderRepository orderRepository;
private final String PRODUCT_ID = "prod-123";
private final int INITIAL_STOCK = 10;
@BeforeAll
void setupDatabaseSchema() {
String createProductsTable = "CREATE TABLE PRODUCTS (id VARCHAR(255) PRIMARY KEY, name VARCHAR(255), stock INT);";
String createOrdersTable = "CREATE TABLE ORDERS (id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255), product_id VARCHAR(255));";
databaseClient.sql(createProductsTable).then().block();
databaseClient.sql(createOrdersTable).then().block();
}
@BeforeEach
void setupTestData() {
databaseClient.sql("DELETE FROM PRODUCTS").then().block();
template.insert(new ProductEntity(PRODUCT_ID, "Test Product", INITIAL_STOCK)).block();
}
@Test
void whenSecondOperationFails_thenRealAspectRollsBackTransaction() {
doReturn(Mono.error(new RuntimeException("DB Error"))).when(orderRepository).save(any());
Mono<Void> result = purchaseUseCase.execute("user-007", PRODUCT_ID);
StepVerifier.create(result).expectError(RuntimeException.class).verify();
ProductEntity productAfter = template.selectOne(query(where("id").is(PRODUCT_ID)), ProductEntity.class).block();
assertThat(productAfter.stock()).isEqualTo(INITIAL_STOCK);
}
@Configuration
@EnableAspectJAutoProxy
@Import(TransactionalUseCaseAspect.class)
static class TestConfig {
@Bean
public R2dbcEntityTemplate r2dbcEntityTemplate(ConnectionFactory connectionFactory) {
return new R2dbcEntityTemplate(connectionFactory);
}
@Bean
public R2dbcTransactionManager transactionManager(ConnectionFactory connectionFactory) {
return new R2dbcTransactionManager(connectionFactory);
}
@Bean
public PurchaseProductUseCasePort purchaseProductUseCase(ProductRepository productRepo, OrderRepository orderRepo) {
return new PurchaseProductUseCase(productRepo, orderRepo);
}
@Bean
public ProductRepository productRepository(R2dbcEntityTemplate template) {
return new R2dbcProductRepositoryAdapter(template);
}
@Bean
public OrderRepository orderRepository(R2dbcEntityTemplate template) {
return new R2dbcOrderRepositoryAdapter(template);
}
}
record Product(String id, int stock) {}
record Order(String userId, String productId) {}
interface ProductRepository { Mono<Product> findById(String id); Mono<Void> updateStock(String id, int stock); }
interface OrderRepository { Mono<Order> save(Order order); }
interface PurchaseProductUseCasePort { Mono<Void> execute(String userId, String productId); }
@Table("PRODUCTS")
record ProductEntity(@Id String id, String name, int stock) {}
@Table("ORDERS")
record OrderEntity(@Id Integer id, String userId, String productId) {}
@Repository
static class R2dbcProductRepositoryAdapter implements ProductRepository {
private final R2dbcEntityTemplate template;
public R2dbcProductRepositoryAdapter(R2dbcEntityTemplate t) { this.template = t; }
public Mono<Product> findById(String id) { return template.selectOne(query(where("id").is(id)),ProductEntity.class).map(e -> new Product(e.id(), e.stock())); }
public Mono<Void> updateStock(String id, int stock) { return template.getDatabaseClient().sql("UPDATE PRODUCTS SET stock = :s WHERE id = :i").bind("s", stock).bind("i", id).fetch().rowsUpdated().then(); }
}
@Repository
static class R2dbcOrderRepositoryAdapter implements OrderRepository {
private final R2dbcEntityTemplate template;
public R2dbcOrderRepositoryAdapter(R2dbcEntityTemplate t) { this.template = t; }
public Mono<Order> save(Order o) { return template.insert(new OrderEntity(null, o.userId(), o.productId())).map(e -> o); }
}
@TransactionalWrapperUseCase
static class PurchaseProductUseCase implements PurchaseProductUseCasePort {
private final ProductRepository pRepo;
private final OrderRepository oRepo;
public PurchaseProductUseCase(ProductRepository p, OrderRepository o) { this.pRepo = p; this.oRepo = o; }
public Mono<Void> execute(String userId, String productId) {
return pRepo.findById(productId).flatMap(p -> pRepo.updateStock(p.id(), p.stock() - 1)).then(oRepo.save(new Order(userId, productId))).then();
}
}
}
Conclusión y Próximos Pasos
Hemos construido una solución completa, limpia y robusta para un problema complejo. Al mantener nuestro dominio puro y delegar las responsabilidades transversales a la capa de aplicación mediante AOP, logramos un código desacoplado, mantenible y altamente testeable. Las dependencias del proyecto reflejan esta arquitectura limpia, utilizando starters de Spring Boot para AOP y R2DBC, y librerías de prueba para H2 y ArchUnit.
// build.gradle
dependencies {
implementation 'org.reactivecommons.utils:object-mapper:0.1.0'
implementation project(':r2dbc-postgresql')
implementation project(':reactive-web')
implementation project(':model')
implementation project(':usecase')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly('org.springframework.boot:spring-boot-devtools')
testImplementation 'com.tngtech.archunit:archunit:1.4.1'
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation 'com.h2database:h2'
testImplementation 'io.r2dbc:r2dbc-h2'
}
Este patrón no se limita a las transacciones. El mismo mecanismo de anotación y aspecto puede extenderse para manejar otras responsabilidades, como la autorización de seguridad, la auditoría o el registro de métricas, consolidándose como una base sólida para el desarrollo de futuras funcionalidades en cualquier aplicación reactiva que aspire a una arquitectura limpia y escalable.