- Agile 2
- Alta disponibilidad 1
- Alternativas cloud 1
- Aop 1
- Arquitectura 3
- Arquitectura distribuida 2
- Automatizacion 3
- Azure devops 1
- Base de datos 1
- Buenas practicas 19
- Cloud 1
- Colas 7
- Competing consumers 1
- Convenciones 11
- Copilot 1
- Diseno 6
- Docker 2
- Docker compose 1
- Documentacion 1
- Eda 11
- Equipos 1
- Escalabilidad 1
- Flujo de negocio 1
- Flujo de trabajo 3
- Flyway 1
- Git 4
- Gradle 3
- Herramientas digitales 1
- Ia 1
- Iam 1
- Infraestructura 2
- Java 14
- Jerarquia tecnica 1
- Jpa 1
- Jsonb 1
- Kafka 7
- Kubernetes 1
- Liderazgo en software 1
- Lineamientos 1
- Log 1
- Logging 3
- Microservicios 3
- Mongodb 1
- Monitoreo 1
- Nosql 3
- Observabilidad 4
- Open source 1
- Plugins 3
- Postgresql 1
- Privacidad 1
- Programacion funcional 1
- Programacion reactiva 4
- Rabbitmq 6
- Rotacion de talento 1
- Saga 2
- Scrum 2
- Security 1
- Seguridad 1
- Self hosting 1
- Sistemas legados 1
- Spring boot 3
- Spring mvc 2
- Sql 3
- Streams 1
- Threadlocal 1
- Trazabilidad 2
- Versionado 2
- Web 1
- Webflux 2
- Websockets 1
- Zero trust 1
Convenciones
11 artículos
Respuestas API Consistentes: Un Wrapper Transversal con Spring WebFlux y `WebFilter`
- Mauricio ECR
- Snippets
- 30 Aug, 2025
En entornos modernos de microservicios, la consistencia en las respuestas de una API es más que una cuestión de estética: es un factor crítico para la mantenibilidad, observabilidad y experiencia del
Respuestas API Consistentes: Un Wrapper Transversal con Spring WebFlux y `WebFilter`
- Mauricio ECR
- Snippets
- 30 Aug, 2025
En entornos modernos de microservicios, la consistencia en las respuestas de una API es más que una cuestión de estética: es un factor crítico para la mantenibilidad, observabilidad y experiencia del consumidor. Aplicaciones frontend, integraciones con terceros, herramientas de monitoreo y otros microservicios esperan estructuras de respuesta predecibles. Cada variación no planificada introduce fricción: más lógica en los clientes, validaciones dispersas y puntos ciegos en trazabilidad.
En este contexto, estandarizar las respuestas de manera transversal —sin ensuciar cada controlador con lógica repetitiva— no solo simplifica el desarrollo, también abre la puerta a métricas uniformes, trazabilidad distribuida y soporte para nuevas funcionalidades sin tocar el código de negocio.
Este artículo explica cómo lograrlo en aplicaciones reactivas con Spring WebFlux, donde la naturaleza streaming de la respuesta introduce desafíos distintos a los de un stack imperativo como Spring MVC.
El Contrato de Respuesta: Mucho más que Datos
Antes de modificar nada, debemos definir el destino. Una respuesta estándar debe separar claramente los datos de negocio de la información contextual que permite entender la petición en su conjunto.
Un diseño común y extensible puede lucir así:
package com.app247.api.shared.response_wrapper.model;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ApiResponse<T> {
private Meta meta;
private T data;
@Data
@Builder
public static class Meta {
private String timestamp;
private String path;
private int status;
private String requestId;
}
}
Este contrato permite:
- Consistencia: cada respuesta, sin importar el endpoint, sigue la misma forma.
- Trazabilidad: con
requestIdytimestamppodemos correlacionar logs, métricas y reportes. - Extensibilidad: podemos agregar campos en
meta(e.g., tiempos de respuesta, versión del servicio) sin afectar al cliente.
En entornos con OpenAPI/Swagger, este modelo puede documentarse fácilmente para que los consumidores conozcan el formato exacto de las respuestas.
WebFlux y el Desafío del Streaming
En aplicaciones no reactivas, ResponseBodyAdvice permite interceptar y modificar respuestas antes de serializarse. Pero en WebFlux, las respuestas son streams (Publisher<DataBuffer>), no objetos finales en memoria.
Esto implica dos retos:
- Respetar el modelo reactivo: no bloquear el flujo ni forzar materializaciones tempranas.
- Actuar en el punto correcto: cuando la respuesta está completa, pero antes de enviarla al cliente.
Aquí entra en juego el dúo WebFilter + ServerHttpResponseDecorator. El filtro decide si aplicar la transformación; el decorador define cómo hacerlo.
El Filtro: Decidiendo Cuándo Intervenir
Nuestro WebFilter actúa como middleware, excluyendo rutas (por ejemplo, Swagger o Actuator) y habilitando/deshabilitando la lógica según configuración externa:
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.decorator.ResponseWrapperDecorator;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
@Slf4j
@Component
@Order(-2)
@RequiredArgsConstructor
public class ResponseWrapperFilter implements WebFilter {
private final ResponseWrapperProperties properties;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// Verificamos si la ruta está excluida
boolean isExcluded = !properties.isEnabled() || properties.getExcludedPaths().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
if (isExcluded) {
return chain.filter(exchange);
}
// Creamos una instancia de nuestro nuevo decorador
ServerHttpResponseDecorator decoratedResponse = new ResponseWrapperDecorator(
exchange.getResponse(),
path,
objectMapper
);
// Pasamos el exchange con la respuesta decorada al siguiente filtro en la cadena
return chain.filter(exchange.mutate().response(decoratedResponse).build());
}
}
Las rutas excluidas y la activación del wrapper se controlan con propiedades externas, evitando recompilar para cambios operativos:
package com.app247.api.shared.response_wrapper.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/*
Ejemplo:
api:
response:
wrapper:
enabled: true
# Patrones de URL para excluir. Usa el formato Ant.
excluded-paths:
- "/v3/api-docs/**"
- "/swagger-ui/**"
- "/webjars/**"
- "/swagger-resources/**"
- "/actuator/**"
*/
@Data
@Component
@ConfigurationProperties(prefix = "api.response.wrapper")
public class ResponseWrapperProperties {
private boolean enabled = true;
private List<String> excludedPaths = new ArrayList<>();
}
El Decorador: Interviniendo sin Romper el Flujo
ServerHttpResponseDecorator nos da acceso al cuerpo de la respuesta. El método clave es writeWith, que recibe el stream de datos antes de enviarlo al cliente.
package com.app247.api.shared.response_wrapper.decorator;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.UUID;
/**
* Decorador para ServerHttpResponse que intercepta las respuestas exitosas
* y las envuelve en una estructura estandarizada de ApiResponse (meta y data).
*/
@Slf4j
public class ResponseWrapperDecorator extends ServerHttpResponseDecorator {
private final ObjectMapper objectMapper;
private final String path;
public ResponseWrapperDecorator(ServerHttpResponse delegate, String path, ObjectMapper objectMapper) {
super(delegate);
this.path = path;
this.objectMapper = objectMapper;
}
/**
* Sobrescribe el método que escribe el cuerpo de la respuesta en el flujo de salida.
* Aquí es donde ocurre toda la magia de la intercepción y transformación.
* @param body El publicador original del cuerpo de la respuesta.
* @return Un Mono<Void> que representa la finalización de la operación de escritura.
*/
@Override
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
// PASO 1: Almacenar el cuerpo completo en un búfer.
// DataBufferUtils.join() consume tod_o el flujo del 'body' y lo une en un solo DataBuffer.
// Esto es CRUCIAL porque crea un punto de sincronización. La lógica siguiente
// no se ejecutará hasta que el controlador haya terminado y el cuerpo completo esté disponible.
Mono<DataBuffer> bufferedBody = DataBufferUtils.join(body)
.defaultIfEmpty(new DefaultDataBufferFactory().wrap(new byte[0])); // Maneja cuerpos vacíos (ej: 204 No Content)
// PASO 2: Usar flatMap para transformar el cuerpo almacenado en búfer.
// El código dentro de flatMap está garantizado a ejecutarse DESPUÉS de que 'bufferedBody' se complete.
return bufferedBody.flatMap(originalBuffer -> {
// PASO 3: Obtener el código de estado.
// En este punto, la llamada a getStatusCode() es 100% fiable porque el controlador
// ya ha finalizado y el framework ha establecido el estado final de la respuesta.
HttpStatusCode statusCode = getStatusCode();
// PASO 4: Decidir si se debe envolver la respuesta.
// Si el estado es un error explícito (4xx o 5xx), no hacemos nada y devolvemos el cuerpo original.
if (statusCode != null && !statusCode.is2xxSuccessful()) {
// Se escribe el buffer original en la respuesta real.
return getDelegate().writeWith(Mono.just(originalBuffer));
}
// PASO 5: Manejar el caso del entorno de pruebas.
// En WebFluxTest, un 200 OK por defecto puede resultar en un statusCode 'null'.
// Asumimos HttpStatus.OK si el estado es null para que las pruebas pasen.
HttpStatusCode statusToUse = (statusCode != null) ? statusCode : HttpStatus.OK;
// PASO 6: Procesar y envolver el cuerpo de la respuesta.
byte[] bytes = new byte[originalBuffer.readableByteCount()];
originalBuffer.read(bytes);
DataBufferUtils.release(originalBuffer); // Liberar memoria del buffer original.
String originalBodyJson = new String(bytes, StandardCharsets.UTF_8);
// Evitar envolver una respuesta que ya tiene nuestro formato.
if (originalBodyJson.contains("\"meta\"")) {
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
try {
// Deserializar el cuerpo original para poder ponerlo dentro del campo 'data'.
// Si el cuerpo está vacío, se asigna 'null' a los datos.
Object originalBodyObject = originalBodyJson.isEmpty() ? null : objectMapper.readValue(originalBodyJson, Object.class);
// Construir la nueva respuesta envuelta.
ApiResponse<?> apiResponse = buildSuccessResponse(originalBodyObject, path, statusToUse);
// Serializar la respuesta envuelta a bytes.
byte[] responseBytes = objectMapper.writeValueAsBytes(apiResponse);
// Actualizar las cabeceras HTTP con la nueva longitud y tipo de contenido.
getHeaders().setContentLength(responseBytes.length);
getHeaders().setContentType(MediaType.APPLICATION_JSON);
// Crear un nuevo buffer con la respuesta envuelta.
DataBuffer wrappedBuffer = new DefaultDataBufferFactory().wrap(responseBytes);
// Escribir el nuevo cuerpo en la respuesta real. Esta es la llamada final y única
// que envía los datos al cliente, siguiendo las buenas prácticas reactivas.
return getDelegate().writeWith(Mono.just(wrappedBuffer));
} catch (Exception e) {
log.error("Error al envolver la respuesta para la ruta {}: {}", path, e.getMessage(), e);
return getDelegate().writeWith(Mono.just(new DefaultDataBufferFactory().wrap(bytes)));
}
});
}
/**
* Método de ayuda para construir la estructura estandarizada de ApiResponse.
* @param data El objeto de datos original que se incluirá en el campo 'data'.
* @param path La ruta de la petición actual.
* @param status El código de estado HTTP final.
* @return Una instancia de ApiResponse.
*/
private ApiResponse<?> buildSuccessResponse(Object data, String path, HttpStatusCode status) {
ApiResponse.Meta meta = ApiResponse.Meta.builder()
.timestamp(Instant.now().toString())
.path(path)
.requestId(UUID.randomUUID().toString().substring(0, 10))
.status(status.value())
.build();
return ApiResponse.builder()
.meta(meta)
.data(data)
.build();
}
}
Consideraciones Técnicas
- Performance:
DataBufferUtils.join()carga todo en memoria; para respuestas muy grandes, conviene evaluar streaming JSON. - Idempotencia: el filtro detecta si ya existe
"meta"para evitar doble envoltura. - Trazabilidad distribuida:
requestIdpuede integrarse con Spring Cloud Sleuth o MDC para correlacionar logs entre microservicios.
Pruebas: Validando Comportamiento y Robustez
Con @WebFluxTest podemos probar controladores y filtros en un entorno aislado.
package com.app247.api.shared.response_wrapper.filter;
import com.app247.api.shared.response_wrapper.config.ResponseWrapperProperties;
import com.app247.api.shared.response_wrapper.model.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.when;
/**
* Pruebas de integración para el ResponseWrapperFilter.
* Valida que las respuestas exitosas se envuelvan y que las de error o excluidas se ignoren.
*/
@WebFluxTest
@Import(ResponseWrapperFilterTest.TestConfig.class)
class ResponseWrapperFilterTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private ResponseWrapperProperties responseWrapperProperties;
@BeforeEach
void setUp() {
when(responseWrapperProperties.isEnabled()).thenReturn(true);
when(responseWrapperProperties.getExcludedPaths()).thenReturn(List.of("/excluded/**"));
}
@Test
@DisplayName("Debería envolver una respuesta Mono exitosa en ApiResponse")
void shouldWrapSuccessfulMonoResponse() {
webTestClient.get().uri("/test/mono")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.meta.path").isEqualTo("/test/mono")
.jsonPath("$.data").exists()
.jsonPath("$.data.id").isEqualTo(1)
.jsonPath("$.data.name").isEqualTo("Test Mono");
}
@Test
@DisplayName("Debería envolver una respuesta Flux exitosa en ApiResponse con una lista")
void shouldWrapSuccessfulFluxResponse() {
webTestClient.get().uri("/test/flux")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data").isArray()
.jsonPath("$.data[0].id").isEqualTo(1)
.jsonPath("$.data[0].name").isEqualTo("Test Flux 1")
.jsonPath("$.data[1].id").isEqualTo(2)
.jsonPath("$.data[1].name").isEqualTo("Test Flux 2");
}
@Test
@DisplayName("No debería envolver una respuesta de una ruta excluida")
void shouldNotWrapExcludedPath() {
webTestClient.get().uri("/excluded/path")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").isEqualTo(99)
.jsonPath("$.name").isEqualTo("Excluded");
}
@Test
@DisplayName("No debería envolver una respuesta de error (ej: 400 Bad Request)")
void shouldNotWrapErrorResponse() {
webTestClient.get().uri("/test/error")
.exchange()
.expectStatus().isBadRequest()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.errors").exists()
.jsonPath("$.errors[0].code").isEqualTo("400-CUSTOM-ERROR")
.jsonPath("$.data").doesNotExist();
}
@Test
@DisplayName("No debería envolver una respuesta que ya tiene el formato ApiResponse")
void shouldNotDoubleWrapAlreadyFormattedResponse() {
webTestClient.get().uri("/test/pre-wrapped")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
.jsonPath("$.meta").exists()
.jsonPath("$.meta.status").isEqualTo(200)
.jsonPath("$.data.message").isEqualTo("This is already wrapped")
.jsonPath("$.data.meta").doesNotExist(); // La comprobación clave: no hay un 'meta' dentro del 'data'.
}
@Test
@DisplayName("Debería devolver un error 500 estándar si la serialización del framework falla")
void shouldReturnStandard500ErrorOnFrameworkSerializationFailure() {
webTestClient.get().uri("/test/unserializable")
.exchange()
// 1. Aserción clave: el estado DEBE ser 500 Internal Server Error.
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody()
// 2. Aserciones sobre el cuerpo de error estándar de Spring Boot.
// Este cuerpo NO es el original, sino el generado por el manejador de errores de Spring.
.jsonPath("$.status").isEqualTo(500)
.jsonPath("$.error").isEqualTo("Internal Server Error")
.jsonPath("$.path").isEqualTo("/test/unserializable")
// 3. Confirmamos que no hay rastro del cuerpo original ni de nuestra envoltura personalizada.
.jsonPath("$.meta").doesNotExist()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.id").doesNotExist();
}
// --- CONFIGURACIÓN INTERNA Y COMPONENTES DE PRUEBA ---
@Data
@NoArgsConstructor
@AllArgsConstructor
static class TestDto {
private int id;
private String name;
}
// DTO diseñado para fallar durante la serialización de Jackson debido a una referencia circular.
@Data
static class UnserializableDto {
private int id = 123;
private Object problematicField = this;
}
@RestController
static class TestController {
@GetMapping("/test/mono")
Mono<TestDto> getMono() {
return Mono.just(new TestDto(1, "Test Mono"));
}
@GetMapping("/test/flux")
Flux<TestDto> getFlux() {
return Flux.just(new TestDto(1, "Test Flux 1"), new TestDto(2, "Test Flux 2"));
}
@GetMapping("/excluded/path")
Mono<TestDto> getExcluded() {
return Mono.just(new TestDto(99, "Excluded"));
}
@GetMapping("/test/error")
Mono<TestDto> getError() {
return Mono.error(new BusinessException("Error forzado", "400-CUSTOM-ERROR"));
}
@GetMapping("/test/pre-wrapped")
Mono<ApiResponse<Map<String, String>>> getPreWrappedResponse() {
ApiResponse.Meta meta = ApiResponse.Meta.builder().status(200).build();
Map<String, String> data = Collections.singletonMap("message", "This is already wrapped");
return Mono.just(ApiResponse.<Map<String, String>>builder().meta(meta).data(data).build());
}
@GetMapping("/test/unserializable")
Mono<UnserializableDto> getUnserializableObject() {
return Mono.just(new UnserializableDto());
}
}
static class BusinessException extends RuntimeException {
private final String errorCode;
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
@org.springframework.web.bind.annotation.RestControllerAdvice
static class TestGlobalExceptionHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(BusinessException.class)
@org.springframework.web.bind.annotation.ResponseStatus(HttpStatus.BAD_REQUEST)
public Mono<Map<String, Object>> handleBusinessException(BusinessException ex) {
Map<String, String> error = Map.of("code", ex.getErrorCode(), "message", ex.getMessage());
Map<String, Object> meta = Map.of("timestamp", Instant.now().toString());
return Mono.just(Map.of("meta", meta, "errors", List.of(error)));
}
}
@Configuration
static class TestConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
@Bean
public AntPathMatcher antPathMatcher() {
return new AntPathMatcher();
}
@Bean
public TestGlobalExceptionHandler testGlobalExceptionHandler() {
return new TestGlobalExceptionHandler();
}
@Bean
public ResponseWrapperFilter responseWrapperFilter(
ResponseWrapperProperties properties, ObjectMapper objectMapper
) {
return new ResponseWrapperFilter(properties, objectMapper);
}
@Bean
public TestController testController() {
return new TestController();
}
}
}
Podemos extender las pruebas con StepVerifier para validar que el flujo sigue siendo reactivo y no introduce bloqueos inesperados.
Próximos Pasos y Extensiones
La solución presentada puede evolucionar hacia:
- Trazabilidad distribuida: Propagando
requestIdcon Spring Cloud Sleuth, Zipkin o Jaeger. - Internacionalización: Soporte para mensajes localizados en errores o advertencias.
- Observabilidad avanzada: Tiempo de procesamiento en
meta, integración con Prometheus o Grafana. - Functional Endpoints: Adaptando la solución a APIs basadas en
RouterFunctionen lugar de anotaciones tradicionales.
Con esta base, la envoltura de respuestas deja de ser solo un detalle de formato y se convierte en una capa estratégica para consistencia, trazabilidad y mantenimiento a largo plazo.
Más Allá del `try-catch`: Diseñando un Sistema de Excepciones Robusto y Escalable 🎯
- Mauricio ECR
- Snippets
- 28 Aug, 2025
En el desarrollo de APIs, el manejo de errores suele ser un ciudadano de segunda clase. Con frecuencia, nos conformamos con respuestas de error genéricas, inconsistentes o, en el peor de los casos, co
Más Allá del `try-catch`: Diseñando un Sistema de Excepciones Robusto y Escalable 🎯
- Mauricio ECR
- Snippets
- 28 Aug, 2025
En el desarrollo de APIs, el manejo de errores suele ser un ciudadano de segunda clase. Con frecuencia, nos conformamos con respuestas de error genéricas, inconsistentes o, en el peor de los casos, con trazas de stack trace en HTML que no aportan valor al consumidor de la API. Un buen contrato de API no solo define las rutas de éxito, sino que también establece un lenguaje claro y predecible para cuando las cosas van mal.
El objetivo de este artículo es construir, paso a paso, una estrategia de manejo de excepciones que sea robusta, escalable y centralizada. Dejaremos atrás los bloques try-catch dispersos por el código de negocio para dar paso a un sistema que produce respuestas JSON consistentes y enriquecidas para cualquier tipo de error, ya sea una validación de negocio, un recurso no encontrado o un fallo inesperado del sistema. Para ello, nos apoyaremos en principios de diseño sólidos como el Patrón Strategy, el Principio de Abierto/Cerrado y un enfoque que mantiene nuestro dominio limpio de preocupaciones de infraestructura.
Definiendo un Lenguaje Común para el Error
Antes de manejar cualquier error, debemos definir cómo queremos comunicarlo. En lugar de depender de estructuras volátiles como Map<String, Object>, estableceremos un contrato sólido mediante Data Transfer Objects (DTOs). Esto nos proporciona seguridad de tipos, autocompletado en el IDE y una excelente base para la documentación automática con herramientas como OpenAPI.
Nuestra estructura de respuesta de error estándar será la siguiente:
{
"meta": {
"timestamp": "2025-08-28T19:12:58.123Z",
"path": "/api/users",
"status": 409,
"requestId": "a1b2c3d4e5"
},
"errors": [
{
"code": "409-001",
"message": "EMAIL ALREADY EXISTS",
"payload": {
"email": "[email protected]"
}
}
]
}
Para modelar esto, definimos tres clases principales. ApiErrorResponse es el contenedor principal, que incluye una sección de metadatos (Meta) y una lista de errores. El ApiError en sí mismo es flexible, con un código, un mensaje y un payload opcional para datos contextuales.
// Modelo de respuesta genérico y estandarizado
@Data
@Builder
public class ApiErrorResponse<T> {
private Meta meta;
private List<T> errors;
@Data
@Builder
public static class Meta {
private String timestamp;
private String path;
private int status;
private String requestId;
}
}
// DTO que representa un único error de la API
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiError(String code, String message, Object payload) {
public ApiError(String code, String message) {
this(code, message, null);
}
}
Finalmente, un pequeño DTO para encapsular el contexto de la petición que se pasará a través de nuestro sistema.
// Encapsula la información del contexto de la request
@Data
@Builder
public class RequestContextApi {
private String path;
private String requestId;
}
Manteniendo el Dominio Puro: La BusinessException
Una de las claves de una buena arquitectura es la separación de conceptos. La lógica de negocio no debería saber nada sobre códigos de estado HTTP o la estructura de una respuesta JSON. Para lograrlo, definimos una excepción base para nuestro dominio, BusinessException.
Esta clase abstracta es simple pero poderosa. Contiene un errorCode único para la aplicación y un payload opcional. Cualquier excepción de negocio específica (ej. InsufficientFundsException) heredará de ella, manteniendo el dominio completamente agnóstico a la tecnología.
@Getter
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final Object payload;
protected BusinessException(String message, String errorCode, Object payload) {
super((message != null) ? message.toUpperCase() : "");
this.errorCode = errorCode;
this.payload = payload;
}
protected BusinessException(String message, String errorCode) {
super((message != null) ? message.toUpperCase() : "");
this.errorCode = errorCode;
this.payload = null;
}
}
El Cerebro de la Operación: El Patrón Strategy
Con los modelos definidos, es hora de diseñar el mecanismo central. En lugar de un gran bloque if-else o un switch para manejar diferentes tipos de excepciones, utilizaremos el Patrón Strategy. Esto nos permitirá encapsular la lógica para manejar cada tipo de excepción en su propia clase, haciendo el sistema increíblemente fácil de extender.
La piedra angular es la interfaz ExceptionHandlerStrategy. Define un contrato que cada manejador debe cumplir:
supports(Class<? extends Throwable> exceptionType): Determina si el manejador es capaz de procesar un tipo de excepción dado.getStatus(Throwable ex): Define elHttpStatusque corresponde a la excepción. Esto nos permite devolver códigos más precisos que un simple 400 o 500.handle(Throwable ex, ...): El método que procesa la excepción. Lo interesante aquí es que proveemos una implementacióndefaultque cubre los casos más comunes, de modo que muchos de nuestros manejadores serán puramente declarativos.
public interface ExceptionHandlerStrategy {
boolean supports(Class<? extends Throwable> exceptionType);
default HttpStatus getStatus(Throwable ex) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
default ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
HttpStatus status = getStatus(ex);
Object error = new ApiError(String.valueOf(status.value()), "INTERNAL_SERVER_ERROR");
return buildErrorResponse(status, context, (error instanceof List<?>) ? (List<?>) error : List.of(error));
}
static ResponseEntity<ApiErrorResponse<?>> buildErrorResponse(
HttpStatus status, RequestContextApi context, List<?> errors) {
// ... Lógica para construir la respuesta final ...
}
}
Estrategias en Acción: El Manejador Específico y el Genérico
Con la interfaz lista, crear manejadores es trivial. Para nuestras BusinessException, creamos un BusinessExceptionHandler. Este manejador sobreescribe getStatus para implementar una lógica ingeniosa que deriva el código de estado HTTP a partir del errorCode de la excepción (ej. "409-001" se convierte en HttpStatus.CONFLICT). También sobreescribe handle para asegurarse de que el payload se incluya en la respuesta.
@Component
public class BusinessExceptionHandler implements ExceptionHandlerStrategy {
@Override
public boolean supports(Class<? extends Throwable> exceptionType) {
return BusinessException.class.isAssignableFrom(exceptionType);
}
@Override
public HttpStatus getStatus(Throwable ex) {
BusinessException businessException = (BusinessException) ex;
String codeHttp = businessException.getErrorCode().split("-")[0];
try {
int codigo = Integer.parseInt(codeHttp);
return HttpStatus.valueOf(codigo);
} catch (Exception e) {
return HttpStatus.BAD_REQUEST;
}
}
@Override
public ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
BusinessException exception = (BusinessException) ex;
HttpStatus status = getStatus(exception);
Object error = new ApiError(exception.getErrorCode(), exception.getMessage(), exception.getPayload());
return ExceptionHandlerStrategy.buildErrorResponse(status, context, (error instanceof List<?>) ? (List<?>) error : List.of(error));
}
}
Para cualquier otra excepción no controlada, tenemos el GenericExceptionStrategyHandler. Gracias a la anotación @Order(Ordered.LOWEST_PRECEDENCE) de Spring, esta estrategia solo se ejecutará si ninguna otra más específica puede manejar la excepción. Es nuestra red de seguridad, y gracias a la implementación default de la interfaz, su código es mínimo.
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class GenericExceptionStrategyHandler implements ExceptionHandlerStrategy {
@Override
public boolean supports(Class<? extends Throwable> exceptionType) {
return true;
}
}
El Orquestador: Poniendo Todo en Marcha
Las estrategias individuales son útiles, pero necesitamos un director de orquesta. Aquí es donde entran el GlobalExceptionHandlerStrategyRegistry y el GlobalExceptionTranslator.
El Registry es una clase simple que se inyecta con una lista de todas las implementaciones de ExceptionHandlerStrategy disponibles en el contexto de Spring. Su única misión es iterar sobre ellas (respetando el @Order) y delegar el control a la primera que declare que puede manejar la excepción.
@Component
public class GlobalExceptionHandlerStrategyRegistry {
private final List<ExceptionHandlerStrategy> strategies;
public GlobalExceptionHandlerStrategyRegistry(List<ExceptionHandlerStrategy> strategies) {
this.strategies = strategies;
}
public ResponseEntity<ApiErrorResponse<?>> handle(Throwable ex, RequestContextApi context) {
return strategies.stream()
.filter(s -> s.supports(ex.getClass()))
.findFirst()
.map(s -> s.handle(ex, context))
.orElseThrow(() -> new IllegalStateException("No suitable exception handler found.", ex));
}
}
Finalmente, el Translator es el punto de entrada. Es una clase anotada con @RestControllerAdvice que captura cualquier Throwable que escape de nuestros controladores. Su responsabilidad es mínima y crucial: crear el RequestContextApi y pasarle la excepción al Registry. No contiene ninguna lógica de negocio, lo que lo mantiene limpio y enfocado.
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionTranslator {
private final GlobalExceptionHandlerStrategyRegistry registry;
@ExceptionHandler(Throwable.class)
public final ResponseEntity<ApiErrorResponse<?>> handleAnyException(
Throwable ex, ServerWebExchange exchange) {
RequestContextApi context = RequestContextApi.builder()
.path(exchange.getRequest().getURI().getPath())
.requestId(UUID.randomUUID().toString().substring(0, 10))
.build();
return registry.handle(ex, context);
}
}
Con todas las piezas en su lugar, podemos visualizar la arquitectura completa y el flujo de una excepción a través de nuestro sistema. El siguiente diagrama ilustra cómo estos componentes colaboran, desde la captura inicial hasta la selección de la estrategia adecuada y la construcción de la respuesta final.
codigo mermaid
classDiagram
class DatabaseConnectionPool {
-DatabaseEngineFactory engineFactory
+dataSourceFromSecret(String, DatabaseConnectionPropertiesFactory) DataSource
}
class DatabaseConnectionPropertiesFactory {
-GenericManager secretsManager
+getDatabaseConnectionProperties(String) DatabaseConnectionProperties
}
class DatabaseConnectionProperties {
-String dbname
-String username
-String password
-String engine
...
}
class DatabaseEngineFactory {
-Map~String, DatabaseEngineStrategy~ strategies
+getStrategy(String) DatabaseEngineStrategy
}
class DatabaseEngineStrategy {
<>
+getName() String
+buildJdbcUrl(...) String
+getValidationQuery() String
}
class PostgresqlStrategy {
+getName() String
...
}
class MysqlStrategy {
+getName() String
...
}
DatabaseConnectionPool ..> DatabaseConnectionPropertiesFactory : uses
DatabaseConnectionPool ..> DatabaseEngineFactory : uses
DatabaseConnectionPropertiesFactory ..> DatabaseConnectionProperties : creates
DatabaseEngineFactory ..> DatabaseEngineStrategy : uses
PostgresqlStrategy --|> DatabaseEngineStrategy : implements
MysqlStrategy --|> DatabaseEngineStrategy : implements
Conclusión y Futuras Mejoras
Hemos construido un sistema de manejo de excepciones que es a la vez potente y elegante. Todas las respuestas de error de nuestra API son ahora consistentes, informativas y se generan a través de un flujo centralizado y predecible. La belleza de este diseño radica en su escalabilidad: añadir soporte para un nuevo tipo de excepción es tan simple como crear una nueva clase Strategy, sin necesidad de modificar el código existente, adhiriéndonos así al Principio de Abierto/Cerrado.
Este sistema, sin embargo, es una base sólida sobre la cual se puede seguir construyendo. Algunas líneas futuras de mejora podrían incluir:
- Integración con Logging: Centralizar el registro de las excepciones completas dentro de los manejadores para un monitoreo más efectivo.
- Internacionalización (i18n): Modificar el
ApiErrory los manejadores para que puedan devolver mensajes de error en diferentes idiomas según las cabeceras de la petición. - Manejadores Específicos de Framework: Crear estrategias para excepciones comunes de frameworks como Spring Security (ej.
AccessDeniedException) para traducirlas a respuestas403 Forbiddencon un formato consistente.
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. Un
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.
El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot
- Mauricio ECR
- Snippets
- 23 Aug, 2025
En el vertiginoso universo del desarrollo de software, donde la seguridad es un pilar innegociable y la agilidad es la moneda de cambio, nos enfrentamos a desafíos que van más allá de la simple lógica
El Arte de Conectar: Forjando un DataSource Dinámico en Spring Boot
- Mauricio ECR
- Snippets
- 23 Aug, 2025
En el vertiginoso universo del desarrollo de software, donde la seguridad es un pilar innegociable y la agilidad es la moneda de cambio, nos enfrentamos a desafíos que van más allá de la simple lógica de negocio. Uno de los más recurrentes y críticos es la gestión de las conexiones a bases de datos. ¿Cómo construimos un sistema que no solo proteja sus credenciales como si fueran las joyas de la corona, sino que también sea lo suficientemente flexible para "hablar" con distintos motores de bases de datos sin despeinarse?
La respuesta no reside en un truco de magia, sino en la elegancia de la buena arquitectura. Este artículo te llevará en un viaje a través de una solución sofisticada en Spring Boot, donde desvelaremos cómo obtener credenciales de forma segura desde un gestor de secretos y, a la vez, emplear el ingenioso patrón de diseño Strategy para crear DataSources que se adaptan dinámicamente a su entorno. Prepárate para transformar una tarea mundana en una pieza de ingeniería de software.
El Mapa de la Arquitectura
Toda gran solución comienza con un plan. Antes de sumergirnos en el código, visualicemos nuestro ecosistema. No se trata de un monolito de lógica enrevesada, sino de un conjunto de componentes especializados que colaboran en perfecta armonía, como una orquesta bien afinada.
Antes de desgranar el código, un buen mapa visual nos ayudará a navegar la solución. El siguiente diagrama de clases ilustra las relaciones y dependencias entre nuestros componentes clave. Observa cómo las fábricas (Factory) orquestan la creación de objetos, mientras que la interfaz DatabaseEngineStrategy actúa como un contrato para sus diferentes implementaciones.
codigo mermaid
classDiagram
class DatabaseConnectionPool {
-DatabaseEngineFactory engineFactory
+dataSourceFromSecret(String, DatabaseConnectionPropertiesFactory) DataSource
}
class DatabaseConnectionPropertiesFactory {
-GenericManager secretsManager
+getDatabaseConnectionProperties(String) DatabaseConnectionProperties
}
class DatabaseConnectionProperties {
-String dbname
-String username
-String password
-String engine
...
}
class DatabaseEngineFactory {
-Map~String, DatabaseEngineStrategy~ strategies
+getStrategy(String) DatabaseEngineStrategy
}
class DatabaseEngineStrategy {
<>
+getName() String
+buildJdbcUrl(...) String
+getValidationQuery() String
}
class PostgresqlStrategy {
+getName() String
...
}
class MysqlStrategy {
+getName() String
...
}
DatabaseConnectionPool ..> DatabaseConnectionPropertiesFactory : uses
DatabaseConnectionPool ..> DatabaseEngineFactory : uses
DatabaseConnectionPropertiesFactory ..> DatabaseConnectionProperties : creates
DatabaseEngineFactory ..> DatabaseEngineStrategy : uses
PostgresqlStrategy --|> DatabaseEngineStrategy : implements
MysqlStrategy --|> DatabaseEngineStrategy : implements
El flujo de nuestra sinfonía es el siguiente:
- El director de orquesta (
DatabaseConnectionPool) necesita la partitura: las propiedades de conexión. Para ello, acude a nuestro "bibliotecario" (DatabaseConnectionPropertiesFactory). - El bibliotecario viaja a una bóveda segura (el gestor de secretos) para recuperar la partitura (
DatabaseConnectionProperties). - La partitura indica qué tipo de instrumento principal se necesita (el
engine, ej. "postgres"). Con esta clave, el director consulta a un "maestro de instrumentos" (DatabaseEngineFactory). - Este maestro selecciona al músico virtuoso adecuado (
DatabaseEngineStrategy) para ese instrumento. - El músico, con su maestría, interpreta la partitura y genera la melodía única: la URL JDBC.
- Finalmente, con todos los elementos en su lugar, el director da la señal y se forma la orquesta completa: un pool de conexiones
HikariDataSourcelisto para actuar.
Ahora, conozcamos a cada uno de los protagonistas de esta obra.
1. El Molde de Nuestros Secretos: DatabaseConnectionProperties
Todo sistema necesita un lenguaje común. Antes de poder manejar nuestros secretos, debemos definir su forma. Aquí es donde entra en juego DatabaseConnectionProperties, nuestro DTO (Data Transfer Object). No es más que el plano que define qué información esperamos encontrar en esa bóveda segura. Con la ayuda de Lombok, su definición es pura simpleza y elegancia.
package com.app247.mecrblog.jpa.config.datasource;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class DatabaseConnectionProperties {
private String dbname;
private String schema;
private String username;
private String password;
private String host;
private Integer port;
private String engine; // La pieza clave que define nuestra estrategia.
private String dbClusterIdentifier;
}
Esta clase es nuestro contrato: cualquier secreto que recuperemos deberá poder amoldarse a esta estructura.
2. El Guardián de los Secretos: DatabaseConnectionPropertiesFactory
La misión de esta fábrica es simple pero crucial: aventurarse en el mundo exterior, dialogar con el gestor de secretos y volver con el botín, ya transformado en nuestro DatabaseConnectionProperties.
package com.app247.mecrblog.jpa.config.datasource;
import org.springframework.stereotype.Component;
// ... (otros imports)
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
@Component
public class DatabaseConnectionPropertiesFactory {
private static final String DATABASE_SCHEMA = "schema";
// Un detalle brillante: no depende de un cliente de AWS o Vault,
// sino de nuestra propia interfaz 'GenericManager'. Pura abstracción.
private final GenericManager secretsManager;
public DatabaseConnectionProperties getDatabaseConnectionProperties(String key) throws SecretException {
var props = secretsManager.getSecret(key, DatabaseConnectionProperties.class);
props.setSchema(DATABASE_SCHEMA);
log.info("Creando DataSource para el motor={} en host: {}...",
props.getEngine(), props.getHost());
return props;
}
}
La verdadera magia aquí es la dependencia de GenericManager. Esta interfaz es nuestro pasaporte universal, permitiéndonos cambiar de proveedor de secretos (de AWS a HashiCorp Vault, por ejemplo) con solo cambiar una implementación, sin que el resto de nuestra aplicación se inmute. Es el arte del desacoplamiento en su máxima expresión.
3. El Arte de la Poliglotía: Adaptabilidad con el Patrón Strategy
Aquí es donde la trama se pone interesante. ¿Qué sucede cuando nuestra aplicación necesita conversar fluidamente con PostgreSQL y, mañana, con MySQL? Podríamos caer en la tentación de un pantanoso bloque if-else o switch, un camino seguro hacia un código frágil y una deuda técnica creciente.
Pero nosotros elegimos un camino más elegante: el patrón Strategy.
El Contrato del Traductor: DatabaseEngineStrategy
Primero, definimos un contrato, una serie de reglas que cualquier "traductor" de dialectos de bases de datos debe seguir.
package com.app247.mecrblog.jpa.config.datasource;
public interface DatabaseEngineStrategy {
// ¿Cómo te llamas? (ej: "postgres", "mysql")
String getName();
// ¿Cómo construyes una URL de conexión en tu idioma?
String buildJdbcUrl(String host, int port, String dbname, String schema);
// ¿Cuál es tu forma de verificar que estás vivo? (Validation Query)
String getValidationQuery();
}
Los Especialistas en Dialectos
Con el contrato en mano, contratamos a nuestros especialistas. Cada uno es un maestro en su propio idioma y se registra como un bean de Spring (@Component).
El experto en PostgreSQL:
package com.app247.mecrblog.jpa.config.datasource.strategy;
import org.springframework.stereotype.Component;
// ...
@Component
public class PostgresqlStrategy implements DatabaseEngineStrategy {
@Override
public String getName() { return "postgres"; }
@Override
public String buildJdbcUrl(String host, int port, String dbname, String schema) {
return String.format("jdbc:postgresql://%s:%d/%s%s", host, port, dbname,
((schema != null) && (!schema.isEmpty())) ? ("?currentSchema=" + schema) : "");
}
@Override
public String getValidationQuery() { return "SELECT 1"; }
}
El experto en MySQL:
package com.app247.mecrblog.jpa.config.datasource.strategy;
import org.springframework.stereotype.Component;
// ...
@Component
public class MysqlStrategy implements DatabaseEngineStrategy {
@Override
public String getName() { return "mysql"; }
@Override
public String buildJdbcUrl(String host, int port, String dbname, String schema) {
return String.format("jdbc:mysql://%s:%d/%s?useSSL=false&serverTimezone=UTC", host, port, dbname);
}
@Override
public String getValidationQuery() { return "SELECT 1"; }
}
Esta estructura es liberadora. ¿Necesitamos soportar Oracle mañana? Simplemente creamos un OracleStrategy sin tocar una sola línea del código existente. Nuestro sistema ha aprendido a crecer.
4. El Maestro de Ceremonias: DatabaseEngineFactory
Ya tenemos a nuestros músicos especialistas, pero necesitamos a alguien que sepa a quién llamar en cada momento. Ese es el rol de DatabaseEngineFactory, nuestro maestro de ceremonias.
package com.app247.mecrblog.jpa.config.datasource;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
// ...
@Slf4j
@Service
public class DatabaseEngineFactory {
private final Map<String, DatabaseEngineStrategy> strategies;
// Gracias a la magia de Spring, el constructor recibe un mapa con todos
// nuestros especialistas (beans de DatabaseEngineStrategy) disponibles.
public DatabaseEngineFactory(Map<String, DatabaseEngineStrategy> strategiesMap) {
this.strategies = strategiesMap.values().stream()
.collect(Collectors.toMap(DatabaseEngineStrategy::getName, Function.identity()));
log.info("Motores de base de datos soportados: {}", this.strategies.keySet());
}
// Dada una clave ("postgres"), devuelve al especialista correcto.
public DatabaseEngineStrategy getStrategy(String engineName) {
DatabaseEngineStrategy strategy = strategies.get(engineName.toLowerCase());
if (strategy == null) {
throw new IllegalArgumentException("Motor de BD no soportado: " + engineName);
}
return strategy;
}
}
Esta fábrica es un ejemplo sublime de cómo el framework Spring puede simplificar nuestro código. En lugar de registrar manualmente cada estrategia, Spring las descubre y nos las entrega listas para usar. La fábrica simplemente las organiza en un mapa para un acceso instantáneo.
5. La Gran Orquesta: Sincronizando Todo en DatabaseConnectionPool
Hemos llegado al acto final. Es hora de que el director suba al podio y una todas las piezas en una sinfonía funcional. La clase DatabaseConnectionPool es nuestro @Configuration principal, el lugar donde la magia realmente ocurre.
package com.app247.mecrblog.jpa.config.datasource;
import javax.sql.DataSource;
// ...
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
// ...
@Slf4j
@Configuration
@RequiredArgsConstructor
@Profile({ "local", "qa", "dev", "pdn" }) // Actuamos solo en los escenarios indicados
public class DatabaseConnectionPool {
// ... (Constantes de configuración de HikariCP)
private final DatabaseEngineFactory engineFactory;
@Bean
public DataSource dataSourceFromSecret(
@Value("${aws.secrets.credentials.db-write}") String secretName,
DatabaseConnectionPropertiesFactory connectionPropertiesFactory) throws SecretException {
// Acto 1: El bibliotecario trae la partitura.
var props = connectionPropertiesFactory.getDatabaseConnectionProperties(secretName);
// Acto 2: El maestro de ceremonias elige al virtuoso.
DatabaseEngineStrategy engine = engineFactory.getStrategy(props.getEngine());
// Acto 3: El virtuoso crea la melodía (la URL JDBC).
String jdbcUrl = engine.buildJdbcUrl(props.getHost(), props.getPort(), props.getDbname(), props.getSchema());
log.info("Creando DataSource con URL: {}", jdbcUrl);
// Gran final: Se forma la orquesta (el pool de conexiones).
return buildHikariDataSource(jdbcUrl, props.getUsername(), props.getPassword(), engine);
}
private DataSource buildHikariDataSource(String jdbcUrl, String username, String password,
DatabaseEngineStrategy engine) {
var config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(username);
config.setPassword(password);
config.setPoolName("jpa-" + engine.getName() + "-hikari-pool");
config.setConnectionTestQuery(engine.getValidationQuery()); // Usamos la frase del especialista
// ... (resto de la configuración del pool)
return new HikariDataSource(config);
}
}
El método dataSourceFromSecret es el corazón palpitante de nuestra aplicación. Orquesta la secuencia de llamadas de una manera tan limpia y declarativa que su lógica se lee casi como prosa.
Telón Final y Futuras Funciones
Lo que hemos creado es más que un simple configurador de DataSource. Es un testimonio de cómo los buenos principios de diseño pueden dar como resultado un sistema que respira:
- Seguro: Las credenciales viven en su fortaleza, lejos de miradas indiscretas.
- Adaptable: Es un políglota de bases de datos, listo para aprender nuevos dialectos en cualquier momento.
- Robusto y Mantenible: Cada componente tiene su lugar y su propósito, haciendo que el sistema sea un placer de mantener y extender.
¿Y qué nos depara el futuro? Esta arquitectura no es un final, sino un punto de partida para nuevas aventuras:
- Mundos Paralelos: Extender la lógica para manejar réplicas de lectura, creando un
DataSourcepara escritura y otro para lectura. - Nuevos Talentos: Incorporar estrategias para Oracle, SQL Server o incluso bases de datos NoSQL con drivers JDBC.
- Inteligencia Dinámica: Hacer que la selección del
schemasea tan dinámica como el resto de la configuración.
Hemos transformado un requisito técnico en una solución elegante, demostrando que el código, en sus mejores momentos, se acerca más al arte que a la ciencia.
Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯
- Mauricio ECR
- Snippets
- 20 Aug, 2025
En el desarrollo de software moderno, escribir código que funciona perfectamente en escenarios ideales es solo el primer paso. La verdadera fortaleza de un sistema se manifiesta cuando debe enfrentar
Manejando el Caos: Una Guía Definitiva sobre Excepciones de Dominio 🎯
- Mauricio ECR
- Snippets
- 20 Aug, 2025
En el desarrollo de software moderno, escribir código que funciona perfectamente en escenarios ideales es solo el primer paso. La verdadera fortaleza de un sistema se manifiesta cuando debe enfrentar situaciones imprevistas, errores y desviaciones del comportamiento esperado. En este contexto, el manejo adecuado de excepciones se convierte en una disciplina fundamental.
Sin embargo, no todas las excepciones son iguales. Mientras que errores como NullPointerException o IOException señalan problemas técnicos o de infraestructura, existe una categoría más rica y expresiva: las Excepciones de Dominio. Estas no representan fallos técnicos, sino violaciones específicas de las reglas, políticas y restricciones del negocio.
Las excepciones de dominio son especialmente valiosas en arquitecturas que siguen los principios de Domain-Driven Design (DDD), ya que permiten que nuestro código comunique directamente en el lenguaje del negocio, encapsulando la lógica de forma explícita y comprensible.
Este artículo establece una base documental completa sobre el tema, explorando un catálogo exhaustivo de excepciones de dominio y presentando un patrón de implementación que mantiene la separación de responsabilidades entre las capas de dominio e infraestructura.
El Corazón del Asunto: Un Catálogo de Excepciones de Dominio
Una arquitectura robusta requiere identificar y nombrar los conceptos con precisión. Para los errores de negocio, esto significa crear una jerarquía de excepciones que comunique exactamente qué regla específica ha sido violada. El siguiente catálogo cubre una amplia gama de escenarios de negocio comunes:
| Excepción | Descripción | Ejemplo de Uso | Código HTTP | DEFAULT_MESSAGE | DEFAULT_CODE |
|---|---|---|---|---|---|
| EntityNotFoundException | La entidad o recurso solicitado no existe | Buscar un usuario con id=99 que no está en la base de datos |
404 Not Found | ENTITY_NOT_FOUND |
404-001 |
| DuplicateEntityException | Ya existe una entidad con un identificador único | Crear un usuario con un email ya registrado | 409 Conflict | DUPLICATE_ENTITY |
409-001 |
| InvalidIdentifierException | El identificador no cumple con el formato requerido | Consultar un producto con ID esperado como UUID usando valor "ABC-###" |
400 Bad Request | INVALID_IDENTIFIER |
400-001 |
| BusinessRuleViolationException | Violación de una regla de negocio fundamental | Retiro bancario que excede el saldo disponible | 422 Unprocessable Entity | BUSINESS_RULE_VIOLATION |
422-001 |
| OperationNotAllowedException | Operación no permitida en el estado actual del recurso | Intentar cancelar un pedido ya entregado | 403 Forbidden | OPERATION_NOT_ALLOWED |
403-001 |
| InconsistentStateException | Estado internamente incoherente en el modelo de dominio | Pedido marcado como "pagado" sin transacciones asociadas | 500 Internal Server Error | INCONSISTENT_STATE |
500-001 |
| ValidationException | Error genérico de validación de datos de entrada | Petición a la API sin campo obligatorio | 400 Bad Request | VALIDATION_FAILED |
400-002 |
| InvalidValueException | Valor de campo fuera de rango o inválido | Crear usuario con edad = -5 |
400 Bad Request | INVALID_VALUE |
400-003 |
| MissingMandatoryValueException | Ausencia de valor obligatorio para la operación | Crear factura sin número de serie | 400 Bad Request | MISSING_MANDATORY_VALUE |
400-004 |
| ConcurrencyException | Conflicto al modificar un recurso en paralelo | Dos usuarios editando el mismo producto simultáneamente | 409 Conflict | CONCURRENCY_CONFLICT |
409-002 |
| OptimisticLockingException | Discordancia en versión de entidad (bloqueo optimista) | Guardar cliente con version=2 cuando la BD tiene version=3 |
409 Conflict | OPTIMISTIC_LOCK_ERROR |
409-003 |
| ReferentialIntegrityException | Violación de restricción de integridad referencial | Eliminar cliente que tiene facturas asociadas | 409 Conflict | REFERENTIAL_INTEGRITY_VIOLATION |
409-004 |
| AuthenticationException | Fallo en proceso de autenticación | Iniciar sesión con contraseña incorrecta | 401 Unauthorized | AUTHENTICATION_FAILED |
401-001 |
| AuthorizationException | Usuario sin permisos necesarios para la operación | Usuario "cliente" intentando acceder a panel de administración | 403 Forbidden | AUTHORIZATION_FAILED |
403-002 |
| SessionExpiredException | Sesión expirada o token inválido | Petición con JWT expirado a endpoint protegido | 401 Unauthorized | SESSION_EXPIRED |
401-002 |
| WorkflowViolationException | Transición inválida en flujo o proceso | Intentar "aprobar" orden de compra no "validada" | 422 Unprocessable Entity | WORKFLOW_VIOLATION |
422-002 |
| TimeoutException | Operación excedió tiempo de espera máximo | Pago en pasarela externa sin respuesta a tiempo | 504 Gateway Timeout | OPERATION_TIMEOUT |
504-001 |
| ExternalSystemUnavailableException | Sistema externo dependiente no disponible | Servicio de inventario caído durante procesamiento de venta | 503 Service Unavailable | EXTERNAL_SYSTEM_UNAVAILABLE |
503-001 |
| InsufficientBalanceException | Fondos o saldo insuficientes | Pagar compra de 200€ con saldo de 100€ | 422 Unprocessable Entity | INSUFFICIENT_BALANCE |
422-003 |
| CurrencyMismatchException | Mezcla de monedas incompatibles | Pagar en USD desde cuenta que opera solo en EUR | 400 Bad Request | CURRENCY_MISMATCH |
400-005 |
| LimitExceededException | Superación de límite definido | Transferir 10.000€ con límite diario de 5.000€ | 429 Too Many Requests | LIMIT_EXCEEDED |
429-001 |
| ConfigurationException | Error o falta de configuración en el dominio | Sistema sin tipo de IVA definido para país específico | 500 Internal Server Error | CONFIGURATION_ERROR |
500-002 |
| UnsupportedOperationException | Operación no soportada o implementada | Exportar reporte a formato obsoleto no desarrollado | 501 Not Implemented | UNSUPPORTED_OPERATION |
501-001 |
Patrón de Implementación: De la Pureza del Dominio a la Realidad de la Infraestructura 💡
Tener una rica jerarquía de excepciones es valioso, pero el verdadero desafío está en manejarlas de forma elegante. El objetivo es que nuestra capa de dominio lance una InsufficientBalanceException sin conocimiento alguno sobre HTTP, mientras que nuestra capa de API REST la traduzca apropiadamente a una respuesta 422 Unprocessable Entity con formato JSON.
La solución combina el Principio de Inversión de Dependencias con el patrón Strategy, creando un sistema flexible y escalable.
1. La Base de Todo: DomainException
Creamos una clase base abstracta de la que heredarán todas nuestras excepciones de dominio. Es un POJO puro, sin dependencias de frameworks:
package com.tuempresa.dominio.excepciones;
/**
* Excepción base del dominio.
*
* Todas las excepciones específicas del dominio deben heredar de esta clase.
* No contiene ninguna referencia a frameworks ni tecnologías (HTTP, DB, etc.)
*
* Permite mantener un "errorCode" que facilita el mapeo en las capas de
* aplicación/infraestructura.
*/
public abstract class DomainException extends RuntimeException {
private final String errorCode;
protected DomainException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
2. Una Excepción Concreta: EntityNotFoundException
Cada excepción de dominio es una clase simple que extiende DomainException, proporcionando sus propios códigos y mensajes por defecto:
package com.tuempresa.dominio.excepciones;
/**
* Se lanza cuando una entidad no puede ser encontrada en el dominio.
*/
public class EntityNotFoundException extends DomainException {
private static final String DEFAULT_MESSAGE = "ENTITY_NOT_FOUND";
private static final String DEFAULT_CODE = "404-001";
public EntityNotFoundException() {
super(DEFAULT_MESSAGE, DEFAULT_CODE);
}
// Constructor opcional para mayor flexibilidad
public EntityNotFoundException(String message, String errorCode) {
super(message, errorCode);
}
}
3. El Traductor: Patrón Strategy para el Manejo de Excepciones
En lugar de un gigantesco bloque if-else o switch, creamos una "estrategia" de manejo para cada excepción. Esto respeta el Principio de Abierto/Cerrado: podemos añadir nuevos manejadores sin modificar código existente.
3.1. La Interfaz Común (DomainExceptionHandlerStrategy)
Define el contrato que todos nuestros manejadores deben cumplir:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.ResponseEntity;
public interface DomainExceptionHandlerStrategy<T extends DomainException> {
/**
* Devuelve el tipo de excepción que este manejador puede procesar.
*/
Class<T> getExceptionType();
/**
* Procesa la excepción y la convierte en una respuesta HTTP.
*/
ResponseEntity<ApiError> handle(T ex);
}
3.2. Un Manejador Específico (EntityNotFoundHandler)
Implementación concreta para EntityNotFoundException. Su única responsabilidad es traducir esta excepción de dominio en un HTTP 404 Not Found:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
@Component
public class EntityNotFoundHandler
implements DomainExceptionHandlerStrategy<EntityNotFoundException> {
@Override
public Class<EntityNotFoundException> getExceptionType() {
return EntityNotFoundException.class;
}
@Override
public ResponseEntity<ApiError> handle(EntityNotFoundException ex) {
ApiError error = new ApiError(ex.getErrorCode(), ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}
4. El Orquestador: DomainExceptionHandlerRegistry 🔨
Este componente central actúa como director de orquesta. Mediante inyección de dependencias de Spring, recibe un Map donde las claves son tipos de excepción y los valores son las estrategias correspondientes:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Component
public class DomainExceptionHandlerRegistry {
private final Map<Class<? extends DomainException>, DomainExceptionHandlerStrategy> strategies;
// Spring inyectará una lista de todos los beans que implementen la interfaz
// y nosotros la convertimos en un Map para un acceso rápido.
public DomainExceptionHandlerRegistry(
java.util.List<DomainExceptionHandlerStrategy> strategyList) {
this.strategies = strategyList.stream()
.collect(Collectors.toMap(
DomainExceptionHandlerStrategy::getExceptionType,
Function.identity()
));
}
@SuppressWarnings("unchecked")
public ResponseEntity<ApiError> handle(DomainException ex) {
// Buscamos la estrategia específica para el tipo de excepción
DomainExceptionHandlerStrategy<DomainException> strategy =
strategies.get(ex.getClass());
if (strategy != null) {
return strategy.handle(ex);
}
// Fallback para excepciones de dominio no mapeadas explícitamente
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError("500-000", "UNEXPECTED_DOMAIN_ERROR"));
}
}
5. La Estructura de Respuesta: ApiError DTO
Un record de Java para estandarizar el formato de nuestras respuestas de error:
package com.tuempresa.infraestructura.excepciones;
// Usamos un record de Java para una clase de datos inmutable y concisa.
public record ApiError(String code, String message) {}
6. La Puerta de Entrada: @RestControllerAdvice
Finalmente, usamos @RestControllerAdvice de Spring para crear un traductor global que intercepta cualquier DomainException no capturada anteriormente:
package com.tuempresa.infraestructura.excepciones;
import com.tuempresa.dominio.excepciones.DomainException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionTranslator extends ResponseEntityExceptionHandler {
private final DomainExceptionHandlerRegistry registry;
public GlobalExceptionTranslator(DomainExceptionHandlerRegistry registry) {
this.registry = registry;
}
@ExceptionHandler(DomainException.class)
public final ResponseEntity<ApiError> handleDomainException(DomainException ex) {
// Toda la lógica compleja está en el registry, aquí solo delegamos.
return registry.handle(ex);
}
}
Beneficios del Patrón Implementado
Este enfoque proporciona múltiples ventajas significativas:
Separación de Responsabilidades: La capa de dominio permanece completamente aislada de las preocupaciones de infraestructura como códigos HTTP o formatos de respuesta.
Extensibilidad: Añadir nuevas excepciones de dominio requiere únicamente crear la excepción y su manejador correspondiente, sin modificar código existente.
Testabilidad: Cada componente puede ser probado independientemente, facilitando la escritura de pruebas unitarias y de integración.
Mantenibilidad: La lógica de manejo de errores está centralizada pero distribuida de forma lógica, evitando el antipatrón de "God Objects".
Reutilización: El mismo patrón puede adaptarse a diferentes protocolos y tecnologías más allá de HTTP/REST.
Conclusión y Futuras Líneas de Trabajo
Hemos establecido una base sólida y documentada para el manejo de errores de negocio. Las excepciones de dominio trascienden la simple gestión de errores para convertirse en una herramienta de modelado que enriquece nuestro código, haciéndolo más expresivo y alineado con las reglas del negocio.
El patrón presentado, fundamentado en Strategy y un Registro central, ofrece una solución elegante que mantiene la pureza de la capa de dominio mientras proporciona un mecanismo extensible para traducir errores de negocio en respuestas concretas de infraestructura.
Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro
- Mauricio ECR
- Arquitectura
- 05 Jul, 2025
En el dinámico mundo del desarrollo de software, la complejidad es el enemigo silencioso. Las aplicaciones crecen, los requisitos cambian y, sin una guía clara, el código puede convertirse rápidamente
Arquitectura DDD y Hexagonal: Construyendo Software para el Futuro
- Mauricio ECR
- Arquitectura
- 05 Jul, 2025
En el dinámico mundo del desarrollo de software, la complejidad es el enemigo silencioso. Las aplicaciones crecen, los requisitos cambian y, sin una guía clara, el código puede convertirse rápidamente en un laberinto frágil y costoso de mantener. ¿La solución? No es un framework de moda, sino una filosofía de diseño sólida. Esta guía ofrece un mapa detallado para construir software robusto, escalable y, sobre todo, alineado con el negocio, fusionando los principios del Diseño Guiado por el Dominio (DDD), la Arquitectura Limpia (Clean Architecture) y la Arquitectura Hexagonal (Puertos y Adaptadores).
Olvídate de las capas anémicas y el acoplamiento tecnológico. Aquí aprenderás a colocar el corazón de tu negocio —el dominio— en el centro del universo, protegido y aislado de los detalles mundanos de la tecnología. Prepárate para diseñar sistemas donde la lógica de negocio es la reina, la infraestructura es un sirviente intercambiable y el cambio es una oportunidad, no una amenaza.
🎯 Capa de Dominio: El Corazón del Negocio
Esta es la capa más sagrada y protegida de la arquitectura. Su único propósito es encapsular la lógica y las reglas de negocio puras, utilizando el Lenguaje Ubicuo (Ubiquitous Language) del problema que se está resolviendo. Es completamente agnóstica a la tecnología; no debe existir ninguna referencia a frameworks, bases de datos o APIs. En Arquitectura Hexagonal, esta capa es el "hexágono" central, y en Arquitectura Limpia, corresponde a los círculos internos de Entidades y Casos de Uso.
Aquí residen los componentes que modelan el negocio: Agregados, Entidades, Objetos de Valor, Eventos de Dominio, Servicios de Dominio, las interfaces de los Repositorios (que actúan como Puertos) y los Casos de Uso que orquestan toda la lógica.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Agregado (Aggregate) | Unidad de consistencia transaccional. Agrupa entidades y objetos de valor bajo una raíz (Aggregate Root) que protege las reglas de negocio del clúster. |
| Entidad (Entity) | Objeto con una identidad única que perdura en el tiempo y un ciclo de vida definido. Su identidad es lo que lo define, no sus atributos. |
| Objeto de Valor (VO) | Objeto inmutable definido por sus atributos, sin una identidad propia. Se utiliza para medir, cuantificar o describir cosas (ej. Dinero, FechaRango). |
| Evento de Dominio | Representa un suceso de negocio relevante que ya ha ocurrido. Sirve para comunicar cambios y desacoplar la lógica entre diferentes partes del sistema. |
| Servicio de Dominio | Encapsula lógica de negocio sin estado que no pertenece de forma natural a ninguna entidad u objeto de valor, a menudo coordinando varios de ellos. |
| Repositorio (Puerto) | Define el contrato para persistir y recuperar agregados. Es una interfaz que dicta las necesidades del dominio sin conocer la tecnología subyacente. |
| Caso de Uso | Orquesta el flujo de una operación. Es el punto de entrada a la lógica de dominio, recibiendo datos de entrada y utilizando los puertos para ejecutar la acción. |
🔌 Capa de Infraestructura: El Mundo de la Tecnología
Esta capa contiene todos los detalles técnicos y las implementaciones concretas. Su finalidad es servir como un conjunto de adaptadores que traducen las interacciones del mundo exterior al lenguaje del dominio, y viceversa. Implementa los puertos definidos en la Capa de Dominio, cumpliendo con la sagrada Regla de la Dependencia: la infraestructura siempre depende del dominio. Aquí residen los frameworks web, las conexiones a bases de datos, los clientes de servicios externos y cualquier otra dependencia del mundo real.
Los componentes clave son los Entry-Points (adaptadores que invocan los casos de uso, como controladores de API REST) y los Driven-Adapters (implementaciones de los puertos del dominio, como un repositorio JPA), junto con sus artefactos de apoyo como DTOs, Modelos de Persistencia y Mappers.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Entry-Point | Adaptador que recibe una señal externa (ej. una petición HTTP, un mensaje de una cola) y la traduce en una llamada a un Caso de Uso. |
| DTO (Data Transfer Object) | Define la estructura de datos para la comunicación externa. Se usa en los Entry-Points para modelar peticiones y respuestas, aislando el dominio. |
| Driven-Adapter | Implementación de un puerto del dominio. Por ejemplo, un repositorio que usa JPA para hablar con una base de datos o un cliente HTTP para consumir otra API. |
| Modelo de Persistencia | Clase que mapea a una estructura de base de datos (ej. una tabla). Es un detalle de implementación del adaptador de persistencia, no es la entidad de dominio. |
| Mapper / Traductor | Utilidad para convertir datos entre capas: DTO ↔ Entidad, Modelo de Persistencia ↔ Entidad. Es el pegamento que permite el desacoplamiento. |
🚀 Capa de Aplicación: El Ensamblador
Esta es la capa más externa y conceptualmente simple. Su única finalidad es ensamblar la aplicación y ponerla en marcha. No contiene lógica de negocio. Es responsable de inicializar el sistema, configurar el contenedor de Inyección de Dependencias (IoC) para conectar las implementaciones de la infraestructura (Driven-Adapters) con las abstracciones del dominio (Puertos), y leer configuraciones externas.
| Componente | Rol y Responsabilidad Clave |
|---|---|
| Contenedor IoC | Configuración de la Inyección de Dependencias. Define "recetas" para construir los objetos, especificando qué Driven-Adapter se debe usar para un Puerto. |
| Configuración | Gestiona los parámetros externos de la aplicación (URLs, credenciales, etc.) a través de archivos (.yml, .properties) o variables de entorno. |
| Punto de Entrada | La clase que contiene el método public static void main(String[] args). Su única función es arrancar el framework y, con él, toda la aplicación. |
📂 Estructura de Módulos Sugerida
Una representación visual de una estructura de módulos (por ejemplo, en Gradle o Maven) que materializa esta arquitectura de forma limpia.
mi-proyecto-escalable/
├── build.gradle.kts
├── settings.gradle.kts # Define los módulos del proyecto
│
├── applications/
│ └── app-service/ # Capa de Aplicación: Ensambla y corre la app
│ └── src/main/java/com/miempresa/app/MainApplication.java
│ └── build.gradle.kts # Depende de 'domain' e 'infrastructure'
│
├── domain/ # Capa de Dominio: Lógica de negocio pura
│ ├── model/ # El modelo: agregados, entidades, VOs, eventos...
│ │ └── src/main/java/com/miempresa/domain/model/producto/Producto.java
│ │ └── src/main/java/com/miempresa/domain/model/producto/gateways/ProductoRepository.java # Puerto (Interfaz)
│ │ └── build.gradle.kts # No tiene dependencias de otras capas
│ └── usecase/ # Los casos de uso que orquestan el modelo
│ └── src/main/java/com/miempresa/domain/usecase/producto/ListarProductosUseCase.java
│ └── build.gradle.kts # Depende de 'domain/model'
│
└── infrastructure/ # Capa de Infraestructura: Detalles tecnológicos
├── entry-points/ # Adaptadores de entrada (ej. API REST)
│ └── rest-api/
│ └── src/main/java/com/miempresa/infrastructure/entrypoints/producto/ProductoController.java
│ └── build.gradle.kts # Depende de 'domain/usecase'
│
└── driven-adapters/ # Adaptadores de salida (ej. Repositorio JPA)
└── jpa-repository/
│ └── src/main/java/com/miempresa/infrastructure/drivenadapters/producto/ProductoData.java # Entidad JPA
│ └── src/main/java/com/miempresa/infrastructure/drivenadapters/producto/ProductoRepositoryAdapter.java # Adaptador
│ └── build.gradle.kts # Depende de 'domain/model'
🗺️ Diagrama de Flujo y Dependencias
Este diagrama ilustra la Regla de la Dependencia (flechas sólidas de dependencia ->) y el Flujo de Control (flechas punteadas de ejecución ...> ). Observa cómo las dependencias siempre apuntan hacia el interior, hacia el dominio, mientras que el flujo de control atraviesa las capas.
+-------------------------------------------------------------------------------------------------+
| Capa de Aplicación (Ensamblador, main) |
+-------------------------------------------------------------------------------------------------+
|
| Inicia y configura
V
+-------------------------------------------------------------------------------------------------+
| Capa de Infraestructura (Adaptadores: REST, DB, etc.) <-- Las flechas de DEPENDENCIA apuntan aquí |
| |
| +---------------+ FLUJO DE CONTROL ...> +--------------------+ |
| | Entry-Point | | Driven-Adapter | |
| | (Controller) | ... ... ... ... ... ... ... ... | (JPA Repository) | |
| +---------------+ . +--------------------+ |
| | ^ . ^ | |
| (Llama) | (Retorna DTO) . (Implementa) | (Habla con DB) |
| | . . . . . | V |
| V | +---------------+ |
| <DEPENDENCIA> <DEPENDENCIA> | Mundo Externo | |
+------+------------------------------------------------------+----------+---------------+-------------+
| |
V V
+-------------------------------------------------------------------------------------------------+
| Capa de Dominio (Lógica de Negocio Pura) <-- TODAS las DEPENDENCIAS apuntan aquí |
| |
| +---------------+ FLUJO DE CONTROL ...> +--------------------+ |
| | Caso de Uso | | Repositorio (Port) | |
| | (Orquestador) | ... ... ... ... ... ... ... ... | (Interfaz) | |
| +---------------+ . +--------------------+ |
| ^ | . |
| | V . |
| | +----------+ |
| +-> | Agregado | <... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...|
| +----------+ |
+-------------------------------------------------------------------------------------------------+
💡 Ejemplo Avanzado: POST /orders (Crear un Pedido)
Veamos cómo fluyen las interacciones en un proceso de negocio real y complejo, como la creación de un nuevo pedido.
1. La Petición del Cliente (Capa de Infraestructura)
- Componente:
OrderController(Entry-Point). - Acción: Recibe una petición
POSTen/orders. Su rol es validar el formato de la petición (usando unPlaceOrderRequestDTO) y delegar inmediatamente al caso de uso correspondiente. No sabe cómo se procesa un pedido, solo a quién llamar. - Artefacto:
PlaceOrderRequestDTO(DTO). Modela el JSON de entrada (customerId,items, etc.). Usa validaciones de framework (@NotNull,@Size) para un rechazo temprano.
2. La Orquestación Central (Capa de Dominio)
- Componente:
PlaceOrderUseCase(Caso de Uso). - Acción: Este es el director de orquesta. No contiene lógica de negocio en sí mismo, pero coordina los pasos en el orden correcto. Su constructor recibe, mediante inyección de dependencias, varios puertos (interfaces):
CustomerRepository,ProductRepository,OrderPricingService,PaymentGateway, yOrderRepository.
3. Recolección y Validación de Negocio (Dominio interactuando con Infraestructura)
- Paso 3.1: Validar Cliente: El caso de uso invoca
customerRepository.findById(customerId). ElCustomerRepositoryAdapter(en infraestructura) lo buscará en la base de datos. Si no existe, el dominio lanza una excepción de negocio (CustomerNotFoundException). - Paso 3.2: Validar Productos y Stock: Para cada ítem, invoca
productRepository.findById(productId). El AgregadoProductrecuperado es responsable de validar sus propias reglas, comoproduct.hasSufficientStock(quantity).
4. Ejecución de Lógica de Negocio Compleja (Dominio)
- Componente:
OrderPricingService(Servicio de Dominio). - Acción: El caso de uso le pasa el cliente y los productos. Este servicio, cuya lógica no encaja en un único agregado, calcula el precio total, aplicando descuentos por lealtad o promociones. Devuelve un Objeto de Valor
Money.
5. Creación del Nuevo Agregado (Dominio)
- Componente:
Order(Agregado Raíz). - Acción: Con todos los datos validados y el precio calculado, el caso de uso invoca un método de fábrica estático:
Order.create(customer, items, totalPrice). El agregadoOrderse crea en un estado inicial válido y registra un evento,OrderPlacedEvent.
6. Interacción con Servicios Externos (Infraestructura)
- Componente:
PaymentGateway(Puertoen el dominio) yStripePaymentAdapter(Driven-Adapteren infraestructura). - Acción: El caso de uso llama a
paymentGateway.processPayment(...). El dominio solo conoce la interfaz. La infraestructura proporciona la implementación concreta (StripePaymentAdapter) que se comunica con la API de Stripe. Si el pago falla, se lanza una excepción que aborta el caso de uso.
7. Persistencia y Efectos Secundarios (Dominio y Infraestructura)
- Paso 7.1: Guardar el Pedido: Si el pago es exitoso, el caso de uso llama a
orderRepository.save(order). ElOrderRepositoryAdapter, usando unOrderDataMapperpara convertir el agregado a unOrderData(entidad JPA), persiste el pedido en la base de datos de forma transaccional. - Paso 7.2: Publicar Evento de Dominio: Tras guardar exitosamente, el caso de uso (o un decorador del repositorio) invoca a un
DomainEventPublisher. Esto despacha elOrderPlacedEventregistrado previamente. Otros módulos del sistema, comoNotificationsoInventory, pueden escuchar este evento y reaccionar de forma totalmente desacoplada (enviar un email, actualizar el stock).
8. La Respuesta Final (Infraestructura)
- Componente:
OrderController(de nuevo). - Acción: Recibe el resultado exitoso del caso de uso (el agregado
Orderrecién creado), lo mapea a unPlaceOrderResponseDTOy devuelve una respuesta201 Createdcon el ID del nuevo pedido.
🏁 Conclusión: Más Allá del Código
Adoptar una arquitectura basada en DDD y Hexagonal no es simplemente organizar carpetas; es un cambio de mentalidad. Nos obliga a dialogar con los expertos del negocio, a modelar la complejidad del mundo real y a proteger esa lógica invaluable de los detalles efímeros de la tecnología.
Los beneficios clave son innegables:
- Testabilidad Superior: La lógica de negocio pura en el dominio puede ser probada unitariamente sin necesidad de frameworks, bases de datos o servidores web.
- Mantenibilidad y Evolución: Cambiar de una base de datos PostgreSQL a MongoDB, o de una API REST a gRPC, se convierte en la tarea de escribir un nuevo adaptador, sin tocar el núcleo del negocio.
- Enfoque en el Negocio: El equipo se centra en resolver problemas de negocio reales, ya que el código refleja directamente el lenguaje y los procesos de la empresa.
- Escalabilidad Organizacional: Diferentes equipos pueden trabajar en distintos adaptadores o módulos del dominio de forma paralela con un bajo riesgo de conflictos.
Esta arquitectura sienta las bases para patrones aún más avanzados como CQRS (Command Query Responsibility Segregation) y Event Sourcing, permitiendo que tus sistemas no solo respondan a las necesidades actuales, sino que estén preparados para prosperar ante los desafíos del futuro.
Estándar de Codificación y Gestión de Errores en Arquitecturas de Microservicios
- Mauricio ECR
- Convenciones
- 18 Jun, 2025
En el ecosistema de aplicaciones web modernas, la arquitectura de microservicios distribuidos se ha consolidado como un paradigma dominante. Su flexibilidad, escalabilidad y resiliencia son innegables
Estándar de Codificación y Gestión de Errores en Arquitecturas de Microservicios
- Mauricio ECR
- Convenciones
- 18 Jun, 2025
En el ecosistema de aplicaciones web modernas, la arquitectura de microservicios distribuidos se ha consolidado como un paradigma dominante. Su flexibilidad, escalabilidad y resiliencia son innegables. Sin embargo, esta distribución introduce una complejidad significativa en la gestión de estados y, especialmente, en el manejo de errores. Cuando una operación involucra a múltiples servicios, identificar la causa raíz de un fallo puede convertirse en una tarea titánica, afectando la experiencia del usuario, los tiempos de resolución y la mantenibilidad del sistema.
Este documento establece un estándar completo y directamente aplicable para la codificación, documentación y gestión de errores visibles al usuario final. El objetivo es crear un marco de trabajo robusto que, aunque inicialmente se implementará en un entorno de backend con Java (Spring Boot) y frontend con Angular, ha sido diseñado con un enfoque agnóstico al lenguaje para garantizar su longevidad y adaptabilidad a futuras tecnologías.
Adoptar este estándar permitirá no solo presentar mensajes de error claros y útiles al usuario, sino también establecer una base documental sólida que facilite la observabilidad, la trazabilidad y la colaboración entre equipos de desarrollo, QA y soporte técnico.
El Estándar Detallado
1. Filosofía y Principios Fundamentales
Antes de detallar la implementación, es crucial entender los principios que guían este estándar:
- El Error como Ciudadano de Primera Clase: Los errores no son un caso excepcional, sino una parte inherente del flujo de una aplicación. Deben ser diseñados, documentados y probados con el mismo rigor que las funcionalidades exitosas.
- Claridad para el Usuario, Detalle para el Desarrollador: La información presentada al usuario final debe ser concisa, clara, traducible y orientada a la acción. Por el contrario, la información registrada para los equipos técnicos debe ser rica en detalles para permitir un diagnóstico rápido y preciso.
- Seguridad por Opacidad: Los códigos y mensajes de error públicos nunca deben revelar detalles de la infraestructura interna, nombres de clases, trazas de pila (stack traces) o cualquier información que pueda ser explotada por un actor malicioso.
- Trazabilidad Extrema: Cada error debe ser unívocamente identificable y correlacionable a través de los distintos servicios y sistemas de observabilidad (logs, métricas, trazas distribuidas).
2. Formato del Código de Error
Todo error visible al usuario final o que cruce los límites de un microservicio deberá ser identificado por un código único y opaco. Este código actúa como una clave inmutable que desacopla el error en sí de su representación (el mensaje).
La estructura propuesta es: MSS-ECNNN[-EXTCODE]
MSS(MicroService Short-identifier): Identificador numérico único de 3 dígitos asignado a cada microservicio. Esta asignación debe ser gestionada en un registro centralizado para evitar colisiones.- Ejemplo:
101para "Servicio de Autenticación",205para "Servicio de Pedidos".
- Ejemplo:
EC(Error Category): Código alfabético de 2 letras que clasifica la naturaleza del error. Esto permite un filtrado y análisis rápido. (Ver sección 3 para el catálogo de categorías).- Ejemplo:
IVpara "Validación de Entrada",BRpara "Regla de Negocio".
- Ejemplo:
NNN(Numeric Sequence): Secuencia numérica de 3 dígitos, única dentro del microservicio para esa categoría. Se recomienda iniciar en001y aumentar de forma secuencial.- Ejemplo:
001,002,047.
- Ejemplo:
[EXTCODE](External Code - Opcional): Componente opcional para encapsular errores originados en servicios de terceros. Proporciona una correlación directa sin exponer el formato original del tercero.- Formato:
EXT_<ID_SERVICIO>_<CODIGO_ERROR_ORIGINAL> ID_SERVICIO: Un identificador corto y predefinido para el servicio externo (ej.STRIPE,SENDGRID,AWS_S3).CODIGO_ERROR_ORIGINAL: El código de error devuelto por el servicio externo, sanitizado para ser compatible con la URL (ej. reemplazando espacios o caracteres especiales por_).- Ejemplo:
205-DP003-EXT_STRIPE_card_declined
- Formato:
Ejemplo completo: 205-BR001 representa el primer error de "Regla de Negocio" definido en el microservicio "Pedidos" (ID 205).
3. Clasificación de Errores Comunes
La estandarización de categorías (EC) es fundamental para la observabilidad y la generación de métricas. El catálogo inicial es el siguiente:
| Código | Categoría | Descripción |
|---|---|---|
IV |
Input Validation | Errores relacionados con la validación de datos de entrada (formato, rango, campos obligatorios). Suelen ser errores del tipo 400 Bad Request. |
AU |
Authentication & Authorization | Fallos de autenticación (token inválido, credenciales incorrectas) o autorización (sin permisos para la acción). Errores 401 Unauthorized o 403 Forbidden. |
BR |
Business Rule | Violación de una regla de negocio específica. La solicitud es sintácticamente correcta pero semánticamente inválida en el contexto actual (ej. stock insuficiente). Suele ser un 409 Conflict o 422 Unprocessable Entity. |
DP |
Dependency Failure | Un servicio externo del que depende el microservicio no está disponible o ha devuelto un error (otra API interna, base de datos, sistema de colas). Errores del tipo 502 Bad Gateway o 503 Service Unavailable. |
SY |
System Error | Errores inesperados o no controlados en el servidor (excepciones RuntimeException, NullPointerException, etc.). Siempre deben ser investigados. Corresponden a un 500 Internal Server Error. |
NT |
Network & Timeout | Errores de comunicación, ya sea porque una dependencia no respondió a tiempo o por problemas en la red. Corresponde a un 504 Gateway Timeout. |
CF |
Configuration Error | El servicio no puede iniciarse o funcionar correctamente debido a una configuración faltante o incorrecta (variables de entorno, secretos, etc.). |
NF |
Not Found | El recurso solicitado no existe. Corresponde a un 440 Not Found. |
4. Catálogo Inicial de Códigos de Error
A continuación, se presenta un catálogo de ejemplo con 10 códigos para dos microservicios hipotéticos: Gestión de Usuarios (ID: 101) y Procesamiento de Pedidos (ID: 205).
| Código | Mensaje usuario | Descripción técnica | Severidad | Estado | Acción esperada | Servicio externo (si aplica) |
|---|---|---|---|---|---|---|
| 101-IV001 | El correo electrónico proporcionado no tiene un formato válido. Por favor, revísalo. | El campo email en el DTO de registro de usuario no supera la validación de la expresión regular ^(.+)@(.+)$. |
Baja | Activo | Usuario: Corregir el formato del email. Frontend: Realizar validación en cliente para prevenir el error. | N/A |
| 101-AU001 | Tu sesión ha expirado. Por favor, inicia sesión de nuevo. | El JWT recibido en la cabecera Authorization ha caducado. La fecha de expiración (exp) es anterior a la fecha actual. |
Media | Activo | Sistema: Redirigir al usuario a la página de login. Usuario: Volver a introducir sus credenciales. | N/A |
| 101-AU002 | No tienes permisos para realizar esta acción. Contacta al administrador si crees que es un error. | El usuario autenticado, extraído del token, no posee el rol requerido (ROLE_ADMIN) para acceder al endpoint. |
Media | Activo | Frontend: Ocultar o deshabilitar la opción que provoca el error. Soporte: Verificar los roles del usuario si este levanta un ticket. | N/A |
| 101-BR001 | El correo electrónico ya está registrado en nuestra plataforma. Intenta iniciar sesión. | Se intentó crear un usuario con un email que ya existe en la tabla users de la base de datos (violación de UNIQUE constraint). |
Baja | Activo | Usuario: Usar la opción "recuperar contraseña" o iniciar sesión. Frontend: Ofrecer un enlace directo a la página de login. | N/A |
| 205-IV001 | El identificador del producto no es válido. Debe ser un número positivo. | El productId recibido en la línea de un pedido es nulo, cero o negativo. La validación @Positive falló en el DTO. |
Baja | Activo | Usuario: Informar del error. Es un caso raro que indica un bug en el frontend. Desarrollo: Investigar cómo se pudo enviar un ID inválido desde el cliente. | N/A |
| 205-BR001 | No hay suficiente stock para el producto 'Nombre del Producto'. Solo quedan X unidades. | La cantidad solicitada de un producto es mayor que el stock disponible registrado en la base de datos para ese productId. |
Media | Activo | Usuario: Reducir la cantidad del producto o eliminarlo del carrito. Sistema: El mensaje debe incluir el stock actual para ser útil. | N/A |
| 205-BR002 | No se pueden añadir productos de diferentes vendedores en un mismo pedido. | La lógica de negocio impide mezclar productos de sellerId distintos en una sola transacción para simplificar la logística. |
Media | Activo | Usuario: Finalizar la compra actual y crear un nuevo pedido para los otros productos. Frontend: Mostrar una advertencia al intentar añadir un producto incompatible. | N/A |
| 205-DP001 | El servicio de inventario no está respondiendo. Por favor, inténtalo de nuevo en unos minutos. | La llamada HTTP al microservicio de Inventario (ID 310) para verificar el stock ha resultado en un timeout o error 5xx. |
Alta | Activo | Sistema: Implementar un reintento con exponential backoff. Si persiste, activar un circuit breaker. Soporte: Revisar el estado del servicio de Inventario. | N/A |
| 205-DP002 | Tu pago no pudo ser procesado por el proveedor. Razón: Fondos insuficientes. | La pasarela de pagos (Stripe) devolvió un error 402 Request Failed con el código card_declined y el motivo insufficient_funds. |
Media | Activo | Usuario: Intentar con otro método de pago o verificar los fondos de su tarjeta. Sistema: No reintentar automáticamente. Invalidar el pedido. | EXT_STRIPE_insufficient_funds |
| 205-SY001 | Ha ocurrido un error inesperado al procesar tu pedido. Nuestro equipo ya ha sido notificado. | Se ha capturado una NullPointerException no esperada en la clase OrderProcessingService al calcular los impuestos. Se ha creado una alerta en Sentry/Datadog. |
Alta | Activo | Sistema: Registrar el trace_id y toda la información de la petición. Desarrollo: Priorizar la corrección de este bug. Usuario: Reintentar más tarde o contactar a soporte con el trace_id si se le muestra. |
N/A |
5. Documentación y Centralización
La documentación es lo que transforma este estándar de una idea a una herramienta útil.
- Estructura de Archivos: Cada microservicio debe incluir en su repositorio un archivo
docs/errors/errors.md. Este archivo contendrá la tabla completa de errores que el servicio puede generar. - Control de Versiones: El archivo
errors.mddebe ser versionado junto con el código fuente del microservicio. Cualquier adición o modificación de un código de error debe formar parte de un Pull Request, permitiendo su revisión. - Formato de Documentación: Se utilizará la tabla Markdown definida en la sección anterior. Este formato es fácil de leer para humanos y de parsear por máquinas.
Ejemplo de microservice-orders/docs/errors/errors.md:
Catálogo de Errores - Servicio de Pedidos
- Nombre Lógico: Servicio de Procesamiento de Pedidos
- ID del Microservicio: 205
| Código | Mensaje usuario | Descripción técnica | Severidad | Estado | Acción esperada | Servicio externo (si aplica) |
|---|---|---|---|---|---|---|
| 205-IV001 | El identificador del producto no es válido. Debe ser un número positivo. | El productId recibido en la línea de un pedido es nulo, cero o negativo. La validación @Positive falló en el DTO. |
Baja | Activo | Usuario: Informar del error. Desarrollo: Investigar cómo se pudo enviar un ID inválido. | N/A |
| ... | ... | ... | ... | ... | ... | ... |
6. Recomendaciones para Centralización y Escalabilidad
Para que el estándar sea efectivo en una organización grande, se recomienda:
- Repositorio Central de Errores: Un pipeline de CI/CD debería agregar los archivos
errors.mdde todos los microservicios tras cada release exitosa en un repositorio central o una página de Confluence/Wiki. Esto crea un único punto de verdad para que los equipos de QA, Soporte y Frontend puedan consultar cualquier código de error sin necesidad de acceder a los repositorios individuales. - Automatización y Linters: El pipeline de CI/CD debe incluir pasos para:
- Validar la Unicidad: Asegurar que no existan códigos
MSS-ECNNNduplicados dentro del mismo microservicio. - Validar el Formato: Comprobar que todos los nuevos códigos sigan la estructura definida.
- Detectar Nuevos Servicios Externos: Lanzar una advertencia si se añade un
EXTCODEcon un<ID_SERVICIO>no registrado previamente, para asegurar que se documente centralmente.
- Validar la Unicidad: Asegurar que no existan códigos
- Generación de Artefactos: Se pueden crear scripts que lean estos archivos
.mdpara generar automáticamente artefactos útiles, como:- Enums de TypeScript para el frontend, permitiendo un manejo de errores tipado (
case ErrorCodes.USER_EMAIL_EXISTS:). - Clases de constantes en Java para el backend.
- Plantillas para sistemas de tickets (Jira, Zendesk).
- Enums de TypeScript para el frontend, permitiendo un manejo de errores tipado (
Conclusión
Este estándar de codificación y gestión de errores proporciona un marco de trabajo unificado y resiliente, diseñado para escalar con la complejidad de una arquitectura de microservicios. Al tratar los errores como un componente central del diseño de software, logramos múltiples beneficios:
- Mejora la Experiencia del Usuario (UX): Los mensajes son claros, consistentes y orientados a la acción.
- Agiliza la Resolución de Incidencias: Los códigos únicos y la documentación detallada permiten a los equipos de soporte y desarrollo identificar y solucionar problemas rápidamente.
- Potencia la Observabilidad: La estructura de códigos y categorías facilita la creación de dashboards, alertas y métricas significativas sobre la salud del sistema.
- Aumenta la Seguridad: La opacidad de los códigos previene la fuga de información sensible de la infraestructura.
Futuras Líneas de Aplicación:
- Integración con Plataformas de Observabilidad: Enriquecer automáticamente las trazas distribuidas (ej. en Jaeger o Datadog) con la "Descripción Técnica" del error a partir de su código.
- Desarrollo de un "Servicio de Errores": Una pequeña API central que, dado un código de error, devuelva su documentación completa, incluyendo el mensaje de usuario localizado en diferentes idiomas.
- Generación de SDKs de Cliente: Automatizar la creación de librerías para diferentes lenguajes (JS/TS, Python, Go) que encapsulen la lógica de manejo de estos códigos de error estandarizados.
La adopción disciplinada de este estándar no es una carga adicional, sino una inversión estratégica en la calidad, mantenibilidad y escalabilidad a largo plazo de la plataforma.
Implementando Trunk Based Development con Ramas de Vida Corta en Tu Equipo: Una Guía Completa
- Mauricio ECR
- DevOps
- 28 Apr, 2025
En el ámbito del desarrollo de software, la elección de una estrategia de versionamiento eficiente y estable es fundamental para el éxito de un equipo. El Trunk Based Development (TBD) tradicional, do
Implementando Trunk Based Development con Ramas de Vida Corta en Tu Equipo: Una Guía Completa
- Mauricio ECR
- DevOps
- 28 Apr, 2025
En el ámbito del desarrollo de software, la elección de una estrategia de versionamiento eficiente y estable es fundamental para el éxito de un equipo. El Trunk Based Development (TBD) tradicional, donde los desarrolladores trabajan directamente sobre una única rama principal (master o main), es ideal para equipos pequeños de 1 a 3 desarrolladores. Para equipos de 4 a 5 desarrolladores, aunque sigue siendo una opción viable, requiere indispensablemente la implementación de pruebas automáticas. Sin embargo, para equipos más grandes, con seis o más desarrolladores, una variante del TBD se presenta como una solución escalable: el Trunk Based Development con Ramas de Vida Corta (TBD con SLB). Esta estrategia, utilizada por empresas como Google, utiliza ramas de vida muy corta para gestionar los cambios en lugar de que los desarrolladores hagan commits directamente en el tronco.
¿En qué Consiste TBD con SLB?
En TBD con SLB, la práctica central es que los desarrolladores no hacen commits directos sobre la rama principal o tronco. En su lugar, trabajan en ramas dedicadas y de muy corta duración. Estas ramas, llamadas ramas de vida corta (short-lived branches), duran muy poco tiempo, idealmente menos de 3 días, y como máximo 3 días. Incluso, pueden contener un solo commit. Una vez que la tarea o funcionalidad de la rama está terminada y lista, se integra de nuevo al tronco mediante un merge back, y la rama de vida corta se elimina.
Implementación Paso a Paso (Desarrollo de Nuevas Funcionalidades)
La implementación de TBD con SLB para añadir o modificar funcionalidades sigue un ciclo bien definido. Suponiendo que la rama principal se llama main (puede ser master en repositorios antiguos).
1. Creación de Ramas de Vida Corta
Cada vez que un desarrollador inicia una nueva tarea, debe crear una nueva rama de desarrollo de vida corta. Esta rama se crea a partir de la última versión del tronco (main). Es crucial asegurarse de que tu copia local del tronco esté actualizada antes de crear la rama.
Comandos Git:
# Asegúrate de estar en la rama principal (trunk)
git checkout main
# Descarga los últimos cambios del tronco remoto
git pull origin main
# Crea una nueva rama de vida corta basada en el tronco actual
# Reemplaza 'feature/nombre-tarea' con un nombre descriptivo para tu tarea
git checkout -b feature/nombre-tarea
Ilustración: La rama feature/nombre-tarea se crea a partir del último commit en main (representado por los commits existentes C1 y C2 en main).
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
checkout feature/nombre-tarea
2. Desarrollo y Commits Locales
El desarrollador trabaja sobre su rama recién creada y realiza los commits necesarios con sus cambios, trabajando a su propio ritmo.
Comandos Git:
# Realiza cambios en tus archivos...
# Por ejemplo, crea o modifica un archivo: touch nuevo-archivo.txt
# Agrega los cambios al área de staging
git add .
# Realiza un commit con un mensaje descriptivo
git commit -m "Implementa la funcionalidad X"
# Repite los pasos add/commit según sea necesario mientras trabajas en la tarea
Ilustración: Nuevos commits (F1, F2) se añaden a la rama feature/nombre-tarea, mientras main permanece inalterada.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
checkout feature/nombre-tarea
commit id: "F1"
commit id: "F2"
3. Sincronización con el Tronco (Previo al Merge)
Antes de preparar la integración de los cambios, es esencial que la rama local del desarrollador esté actualizada con los últimos cambios que ya se encuentran en el tronco. Esto se logra descargando los cambios del tronco y aplicándolos a tu rama, típicamente usando merge o rebase. rebase es a menudo preferido en TBD para mantener un historial más lineal.
Comandos Git (Usando Rebase - Preferido en TBD para historial limpio):
# Asegúrate de que tu tronco local esté actualizado
git checkout main
git pull origin main
# Vuelve a tu rama de trabajo
git checkout feature/nombre-tarea
# Rebase tu rama sobre el tronco actualizado
# Esto reescribe el historial de tu rama para que parezca que tus cambios
# se hicieron después de los últimos cambios del tronco
git rebase main
Comandos Git (Usando Merge - Alternativa):
# Asegúrate de que tu tronco local esté actualizado
git checkout main
git pull origin main
# Vuelve a tu rama de trabajo
git checkout feature/nombre-tarea
# Fusiona los cambios del tronco en tu rama
# Esto crea un commit de merge si hay cambios en el tronco
git merge main
Ilustración: main ha avanzado con C3. El merge crea un nuevo commit (M) en feature/nombre-tarea que combina los cambios de main y los de la rama feature.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
commit id: "F1"
commit id: "F2"
checkout main
commit id: "C3"
checkout feature/nombre-tarea
merge main id: "M"
4. Creación de un Pull Request (PR)
Una vez que los cambios están completos, la rama está sincronizada y lista para integrarse, el desarrollador crea un Pull Request (PR) dirigido al tronco (main). Este paso generalmente se realiza a través de la interfaz web de la plataforma de gestión de código (GitHub, GitLab, Bitbucket, Azure DevOps, etc.), después de haber subido la rama local al repositorio remoto.
Comandos Git (para subir la rama antes del PR):
# Sube tu rama de vida corta al repositorio remoto
# La opción -u (o --set-upstream-to) configura el seguimiento remoto
git push -u origin feature/nombre-tarea
5. Revisión de Código y Pruebas Automáticas Pre-Merge
El uso de PRs es una característica distintiva y ventajosa del TBD con SLB. Permite la revisión de código por otros miembros del equipo y, crucialmente, habilita la ejecución de pruebas automáticas antes de que se realice el merge. Esto es una diferencia significativa con el TBD tradicional, donde las pruebas automáticas a menudo se realizan después del merge. La ventaja clave es que evita que commits con errores lleguen al tronco. Este paso es un proceso que ocurre en la plataforma de código, no un comando Git ejecutado por el usuario para realizar la revisión o las pruebas, aunque los revisores pueden descargar la rama si necesitan probar localmente.
6. Merge al Tronco y Eliminación de la Rama
Si el PR es aprobado (por revisores) y las pruebas automáticas pasan, los cambios de la rama de vida corta se integran al tronco (main) mediante un merge (generalmente completando el PR en la plataforma). Posteriormente, la rama de vida corta es eliminada. Todo desarrollo futuro para nuevas tareas siempre empieza creando una nueva rama de vida corta desde el tronco.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
branch feature/nombre-tarea
commit id: "F1"
commit id: "F2"
checkout main
commit id: "C3"
checkout feature/nombre-tarea
merge main id: "M"
checkout main
merge feature/nombre-tarea
Comandos Git (Después de completar el PR en la plataforma):
# Vuelve al tronco local
git checkout main
# Asegúrate de tener el último estado del tronco (que ahora incluye el merge)
git pull origin main
# Elimina la rama de vida corta localmente (usa -D para forzar si no se ha mergeado, -d es seguro)
git branch -d feature/nombre-tarea
# Elimina la rama de vida corta del repositorio remoto
git push origin --delete feature/nombre-tarea
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1" tag: "merge tarea 1"
Cómo se Gestionan los Releases (Versiones para Producción)
Una parte fundamental del versionamiento es la gestión de las liberaciones de software (releases) a entornos productivos. En el TBD con SLB, el manejo de las ramas de release es directo y se deriva del tronco:
- Creación de la Rama de Release: Cuando el estado actual del tronco (
main) se considera listo para ser liberado como una nueva versión, simplemente se crea una nueva rama a partir de ese punto. - Denominación y Uso: Esta nueva rama se nombra típicamente con el número de la versión que se va a lanzar (por ejemplo,
release/1.0.0). Esta rama de versión es la que se utiliza para ser desplegada en producción. Es la línea base a partir de la cual se gestionarán los despliegues y, si es necesario, las correcciones de bugs específicos de esa versión. El tronco (main) sigue siendo la rama activa para el desarrollo de nuevas funcionalidades.
Comandos Git:
# Asegúrate de estar en el tronco y que esté actualizado
git checkout main
git pull origin main
# Crea la rama de release a partir del tronco actual
# Reemplaza 'release/1.0.0' con el nombre de tu versión
git checkout -b release/1.0.0
# Opcional pero recomendado: Etiqueta este punto para una referencia clara de la versión
git tag v1.0.0 release/1.0.0
# Sube la rama de release y la etiqueta al remoto
git push -u origin release/1.0.0
git push origin v1.0.0
Ilustración: Se crea la rama release/1.0.0 (y potencialmente se etiqueta v1.0.0) a partir de un commit específico en main (C5). main continúa para el desarrollo futuro.
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1" tag: "merge tarea 1"
Escenarios para Solucionar Bugs en Producción
Los bugs que se descubren en una versión ya desplegada en producción (es decir, en la rama de Release) deben manejarse cuidadosamente para mantener la coherencia con la metodología TBD y la integridad del tronco. La forma de proceder depende de si el error aún se puede reproducir en la versión actual del tronco.
Escenario 1: El Bug Sigue Existiendo en el Tronco (Preferido)
Este es el escenario recomendado y se alinea con la práctica de empresas como Google. Se corrige el bug en el tronco y luego se "trasplanta" esa corrección a la rama de Release.
Verificar el Bug en el Tronco: Se confirma que el error se puede replicar en la versión actual del tronco (
main). (Esto es una verificación manual o automatizada, no un comando Git).Crear Rama de Vida Corta desde el Tronco: Se crea una nueva rama de vida corta específicamente para el bug fix, partiendo directamente del tronco.
Comandos Git:
# Asegúrate de estar en el tronco y actualizado
git checkout main
git pull origin main
# Crea la rama para el bug fix desde el tronco
git checkout -b bugfix/nombre-bug-en-produccion
- Solucionar el Bug: El bug se corrige en esta nueva rama de vida corta.
Comandos Git:
# Realiza los cambios para corregir el bug...
# Agrega y realiza el commit
git add .
git commit -m "Fix: Corrige el bug X reportado en v1.0.0 (también presente en main)"
- Merge del Bug Fix al Tronco: El commit que contiene la solución del bug se integra de vuelta al tronco (
main). Esto se hace mediante el proceso habitual de TBD con SLB (usando un PR).
Comandos Git (Después de completar el PR en la plataforma):
# Sube la rama con el bug fix
git push -u origin bugfix/nombre-bug-en-produccion
# Crea un PR en la plataforma de 'bugfix/nombre-bug-en-produccion' a 'main'
# ... (Una vez aprobado y mergeado) ...
# Vuelve al tronco local y actalízalo
git checkout main
git pull origin main
- Cherry Pick a la Rama de Release: Finalmente, se realiza un Cherry Pick del commit específico que contiene el bug fix (el commit
B1original o el merge commitM_B1, aunqueB1es más limpio para cherry-pick) desde el tronco (main) hacia la rama de la versión en producción (la ramarelease/1.0.0donde se encontró el bug).
Comandos Git:
# Identifica el hash del commit de bug fix en el tronco (ej: el commit B1 original o M_B1)
# Puedes usar 'git log main' para encontrarlo
# Vuelve a la rama de release
git checkout release/1.0.0
# Aplica el commit de bug fix desde el tronco a esta rama
# Reemplaza <commit-hash-del-bugfix> con el hash real
git cherry-pick <commit-hash-del-bugfix>
# Sube la rama de release actualizada al remoto
git push origin release/1.0.0
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1"
branch release/1.0.0
checkout release/1.0.0
commit id: "R1" tag: "v1.0.0"
checkout main
commit id: "C4"
commit id: "C5"
branch bugfix/nombre-bug-en-produccion
checkout bugfix/nombre-bug-en-produccion
commit id: "B1" tag: "Bugfix Commit"
checkout main
merge bugfix/nombre-bug-en-produccion id: "M_B1" tag: "Bugfix on Main"
checkout release/1.0.0
cherry-pick id:"M_B1" parent:"B1" tag:"v1.0.1"
Este proceso, aunque involucra más pasos (crear rama, merge al tronco, cherry pick a la rama de Release), garantiza que solo el bug fix se incorpore a la versión de producción. Es la forma de respetar el contrato del Trunk Based Development y asegurar que el tronco sigue siendo la fuente principal de verdad. El desarrollo normal de nuevas funcionalidades continúa siempre en nuevas ramas de vida corta creadas a partir del tronco. Google utiliza este enfoque: las soluciones de bugs para un release se desarrollan en la línea principal y luego se hace un cherry pick a la rama de release.
Escenario 2: El Bug NO Sigue Existiendo en el Tronco (Menos Sugerido)
Este escenario es menos común y no es el recomendado por los expertos en TBD. Ocurre si el bug ya fue corregido en el tronco por un commit que, sin embargo, incluye otras funcionalidades que no deben ir a la rama de Release actual, lo que impide crear la rama de bug fix desde el tronco.
Verificar el Bug y el Tronco: Se confirma que el bug no es replicable en el tronco, pero la solución en el tronco está ligada a otros cambios no deseados en la versión de producción. (Verificación manual/automatizada).
Crear Rama de Vida Corta desde la Rama de Release: En esta situación excepcional, se crea una rama de vida corta desde la rama de la versión en producción (la rama
release/1.0.0).
Comandos Git:
# Asegúrate de estar en la rama de release y actualizada
git checkout release/1.0.0
git pull origin release/1.0.0
# Crea la rama para el hotfix directamente desde la rama de release
git checkout -b hotfix/nombre-bug-solo-en-v1.0.0
- Solucionar el Bug: El bug se corrige en esta rama creada directamente desde la rama de Release.
Comandos Git:
# Realiza los cambios para corregir el bug...
# Agrega y realiza el commit
git add .
git commit -m "Hotfix: Corrige el bug Y específicamente en v1.0.0"
- Merge del Bug Fix a la Rama de Release: Se integra el bug fix de vuelta a la rama de la versión en producción (
release/1.0.0) mediante un merge (idealmente vía PR). La solución está ahora en la versión que la necesita.
Comandos Git (Después de completar el PR en la plataforma):
# Sube la rama con el hotfix
git push -u origin hotfix/nombre-bug-solo-en-v1.0.0
# Crea un PR en la plataforma de 'hotfix/nombre-bug-solo-en-v1.0.0' a 'release/1.0.0'
# ... (Una vez aprobado y mergeado) ...
# Vuelve a la rama de release local y actualízala
git checkout release/1.0.0
git pull origin release/1.0.0
# La rama hotfix/nombre-bug-solo-en-v1.0.0 ahora se eliminaría
- Merge de la Rama de Release al Tronco: Por último, se integra la rama de la versión en producción actualizada (
release/1.0.0) de vuelta al tronco (main). Esto es crucial para asegurar que la corrección hecha en la rama de Release también se refleje en el tronco, evitando la divergencia y que el mismo bug reaparezca en futuras versiones creadas desdemain.
Comandos Git:
# Asegúrate de estar en el tronco y actualizado
git checkout main
git pull origin main
# Fusiona la rama de release (ahora con el hotfix) en el tronco
git merge release/1.0.0
# Sube el tronco actualizado al remoto
git push origin main
codigo mermaid
gitGraph
commit id: "C1"
commit id: "C2"
commit id: "C3"
commit id: "T1"
branch release/1.0.0
checkout release/1.0.0
commit id: "R1" tag: "v1.0.0"
checkout main
commit id: "C4"
commit id: "C5"
checkout release/1.0.0
branch bugfix/nombre-bug-en-produccion
checkout bugfix/nombre-bug-en-produccion
commit id: "B1" tag: "Bugfix Commit"
checkout release/1.0.0
merge bugfix/nombre-bug-en-produccion id:"M_B1" tag: "v1.0.1"
checkout main
cherry-pick id:"M_B1" parent:"B1"
Este escenario invierte el flujo habitual de los cambios. Una vez completado este proceso, el desarrollo continúa siempre en nuevas ramas de vida corta creadas desde el tronco.
Características Clave y Beneficios del TBD con SLB
El TBD con SLB se define por las siguientes características y aporta importantes beneficios:
- Ramas de Vida Corta: Las ramas de desarrollo duran muy poco, idealmente menos de 4 horas y un máximo de 3 días.
- Uso de Pull Requests: La integración de cambios al tronco se realiza exclusivamente a través de PRs, lo que trae consigo sus beneficios.
- Pruebas Automáticas Pre-Merge: Permite la ejecución de pruebas automatizadas antes de fusionar los cambios al tronco, lo que mejora significativamente la estabilidad del tronco al prevenir que lleguen commits con errores.
- Evita "Merge Hell": Al trabajar con ramas pequeñas e integrar cambios con mucha frecuencia, se minimizan los conflictos de merge dolorosos que son comunes con ramas de vida larga.
- Soporte para Versionamiento: Se gestionan versiones creando ramas de Release a partir del tronco.
- Escalabilidad: Es una variante escalable del TBD, siendo muy adecuada para equipos grandes con más de seis desarrolladores.
- Alineación con Prácticas de Grandes Empresas: Es una estrategia utilizada por compañías como Google, donde el desarrollo en ramas (excepto para releases) es inusual y las ramas de vida larga son extremadamente raras.
Conclusión
Implementar Trunk Based Development con Ramas de Vida Corta es una estrategia de versionamiento robusta, profesional y altamente escalable que se adapta bien a equipos de desarrollo grandes. Al basarse en la creación, el trabajo y la rápida fusión (vía PR y con pruebas pre-merge) de ramas muy pequeñas, esta metodología mantiene el tronco (master o main) en un estado más estable y listo para ser desplegado en cualquier momento. Aunque la gestión de bug fixes para versiones en producción requiere un proceso específico (preferiblemente el escenario 1, con Cherry Pick desde el tronco a la rama de Release), el flujo de trabajo general simplifica la integración continua, reduce drásticamente los conflictos de fusión (merge hell) y facilita un ritmo de desarrollo ágil y confiable. Su adopción por grandes empresas como Google subraya su eficacia y solidez como una práctica de versionamiento moderna y eficiente.
Desarrollo de Software Implementando Gitflow
- Mauricio ECR
- DevOps
- 19 Mar, 2025
Introducción El desarrollo de software requiere metodologías y flujos de trabajo que permitan un control eficiente del código fuente. Uno de los enfoques más utilizados para la gestión de versiones
Desarrollo de Software Implementando Gitflow
- Mauricio ECR
- DevOps
- 19 Mar, 2025
Introducción
El desarrollo de software requiere metodologías y flujos de trabajo que permitan un control eficiente del código fuente. Uno de los enfoques más utilizados para la gestión de versiones es Gitflow, una estrategia basada en Git que organiza el trabajo en ramas bien definidas.
Este artículo explora qué es Gitflow, su filosofía, los problemas que resuelve y cómo implementarlo paso a paso en un proyecto. También se comparará el uso de Gitflow con los comandos básicos de Git para quienes prefieran un enfoque manual.
¿Qué es Gitflow?
Gitflow es un modelo de ramificación para Git propuesto por Vincent Driessen. Define un conjunto de reglas y procedimientos para gestionar las diferentes etapas del desarrollo de software, asegurando estabilidad en la rama principal mientras se permite la integración de nuevas funcionalidades de manera organizada.
Filosofía de Gitflow
Gitflow se basa en la idea de separar el desarrollo en ramas específicas con propósitos bien definidos:
Explicación de cada rama en Gitflow
- main: Contiene la versión estable y en producción del software. Solo se realizan fusiones de versiones terminadas y correcciones críticas.
- develop: Es la rama principal de desarrollo donde se integran nuevas funcionalidades antes de ser lanzadas.
- feature: Se crean a partir de
developpara el desarrollo de nuevas características. Una vez terminadas, se fusionan de nuevo endevelop. - release: Se crean a partir de
developcuando se prepara una nueva versión. Aquí se realizan pruebas finales antes de integrarla enmain. - hotfix: Se crean a partir de
mainpara corregir errores críticos en producción y luego se fusionan enmainydevelop. - bugfix: Similar a
hotfix, pero se crean a partir dedeveloppara corregir errores antes de un lanzamiento.
¿Qué busca solucionar?
Gitflow aborda varios problemas comunes en el desarrollo de software, como:
- Integración desorganizada de nuevas características.
- Problemas al manejar versiones de lanzamiento.
- Falta de control sobre la corrección de errores en producción.
¿Cómo propone solucionarlo?
Gitflow impone un flujo de trabajo con reglas claras para la creación, fusión y eliminación de ramas, asegurando estabilidad y organización en el repositorio.
Implementación de Gitflow paso a paso
A continuación, se detalla cómo implementar Gitflow en un proyecto, tanto utilizando la herramienta git-flow como con comandos básicos de Git.
1. Inicializar un repositorio con Gitflow
Usando la herramienta git-flow:
git flow init
Manualmente con Git:
git init
git branch -M main
git checkout -b develop
2. Crear una nueva funcionalidad (feature)
Con git-flow:
git flow feature start nueva-funcionalidad
Con Git básico:
git checkout -b feature/nueva-funcionalidad develop
3. Finalizar una funcionalidad
Con git-flow:
git flow feature finish nueva-funcionalidad
Con Git:
git checkout develop
git merge --no-ff feature/nueva-funcionalidad
git branch -d feature/nueva-funcionalidad
4. Preparar una versión de lanzamiento
Con git-flow:
git flow release start v1.0.0
Con Git:
git checkout -b release/v1.0.0 develop
5. Finalizar una versión de lanzamiento
Con git-flow:
git flow release finish v1.0.0
Con Git:
git checkout main
git merge --no-ff release/v1.0.0
git tag -a v1.0.0 -m "Versión 1.0.0"
git checkout develop
git merge main
git branch -d release/v1.0.0
6. Aplicar una corrección en producción (hotfix)
Con git-flow:
git flow hotfix start fix-critico
Con Git:
git checkout -b hotfix/fix-critico main
Para finalizar el hotfix: Con git-flow:
git flow hotfix finish fix-critico
Con Git:
git checkout main
git merge --no-ff hotfix/fix-critico
git tag -a v1.0.1 -m "Corrección crítica"
git checkout develop
git merge main
git branch -d hotfix/fix-critico
Ventajas del uso de Gitflow
- Estructura clara: Organización de ramas para cada propósito.
- Mayor estabilidad: La rama principal se mantiene siempre en estado funcional.
- Mejor colaboración: Facilita la integración de cambios en equipos grandes.
- Gestión eficiente de versiones: Simplifica la publicación de nuevas versiones y correcciones de errores.
Conclusión
Gitflow es un modelo altamente eficiente para la gestión de versiones en proyectos de software. Aunque la herramienta git-flow automatiza muchos pasos, comprender los comandos básicos de Git permite un control más detallado del flujo de trabajo. Su implementación mejora la organización, facilita el trabajo en equipo y contribuye a la estabilidad del código en producción.
Estructuración de Carpetas en Proyectos de Software
- Mauricio ECR
- Convenciones
- 18 Mar, 2025
En proyectos de software de gran escala, la falta de una estructura de carpetas bien definida puede generar desorden, dificultando la mantenibilidad, escalabilidad y comprensión del código. Muchas vec
Estructuración de Carpetas en Proyectos de Software
- Mauricio ECR
- Convenciones
- 18 Mar, 2025
En proyectos de software de gran escala, la falta de una estructura de carpetas bien definida puede generar desorden, dificultando la mantenibilidad, escalabilidad y comprensión del código. Muchas veces, los proyectos crecen de manera descontrolada sin una estructura definida, lo que lleva a código difícil de navegar, dependencias circulares entre módulos, acoplamiento innecesario entre componentes y dificultad para incorporar nuevos desarrolladores al equipo.
Para estructurar un proyecto de software de manera eficiente, es recomendable seguir una organización de carpetas que refleje claramente la separación de responsabilidades. Esto ayuda a mantener un código modular, organizado y fácil de mantener.
Los principios clave para la organización de carpetas incluyen modularidad, donde cada módulo representa una unidad de negocio independiente; independencia, separando la lógica de negocio de la infraestructura y los adaptadores; cohesión, manteniendo agrupados los elementos relacionados dentro de un módulo; y evolución, permitiendo la escalabilidad sin afectar la estructura global.
Una estructura de carpetas recomendada puede verse de la siguiente manera:
/project-root
├── src
│ ├── core # Reglas de negocio y modelos
│ │ ├── domain
│ │ │ ├── entities
│ │ │ ├── value_objects
│ │ │ ├── repositories
│ │ │ ├── services
│ │ │ └── events
│ │ ├── application
│ │ │ ├── use_cases
│ │ │ ├── dtos
│ │ │ ├── mappers
│ │ │ └── queries
│ ├── modules # Módulos específicos del negocio
│ │ ├── user_management
│ │ │ ├── domain
│ │ │ ├── application
│ │ │ ├── infrastructure
│ │ │ ├── adapters
│ ├── infrastructure # Implementaciones técnicas y frameworks
│ │ ├── persistence
│ │ ├── messaging
│ │ ├── external_services
│ │ ├── config
│ ├── adapters # Interfaces externas y controladores
│ │ ├── api
│ │ ├── cli
│ │ ├── event_consumers
│ │ └── schedulers
├── tests # Pruebas unitarias y de integración
├── docs # Documentación del proyecto
├── scripts # Herramientas para automatización
├── config # Configuración general del proyecto
├── .gitignore
├── README.md
Dentro de esta estructura, 'core/' contiene la lógica de negocio (entidades, servicios, repositorios, etc.); 'modules/' agrupa los módulos del dominio de manera aislada; 'infrastructure/' contiene implementaciones técnicas como bases de datos, servicios externos y configuraciones; 'adapters/' define las interfaces de comunicación con el mundo exterior, incluyendo APIs, CLI y eventos; 'tests/' contiene pruebas unitarias e integración; 'docs/' almacena documentación técnica del proyecto; 'config/' centraliza archivos de configuración; y 'scripts/' incluye herramientas de automatización.
Por ejemplo, en un sistema de gestión de usuarios con autenticación, podríamos estructurar el módulo correspondiente de la siguiente manera:
/modules/user_management
├── domain
│ ├── entities
│ │ ├── UserEnt.ts
│ ├── value_objects
│ │ ├── EmailVO.ts
│ ├── repositories
│ │ ├── UserRepo.ts
├── application
│ ├── use_cases
│ │ ├── RegisterUserUC.ts
│ │ ├── AuthenticateUserUC.ts
│ ├── dtos
│ │ ├── UserDTO.ts
│ ├── mappers
│ │ ├── UserMap.ts
├── infrastructure
│ ├── persistence
│ │ ├── UserRepoImpl.ts
├── adapters
│ ├── api
│ │ ├── UserCtrl.ts
Aquí, la capa de dominio maneja las reglas de negocio, la capa de aplicación contiene los casos de uso, la infraestructura gestiona la persistencia y los adaptadores exponen las funcionalidades a través de controladores API.
Resumen de Carpetas y su Uso
| Carpeta | Descripción | Ejemplo en 'user_management' |
|---|---|---|
| 'core/domain' | Contiene entidades, objetos de valor, repositorios y servicios | 'UserEnt.ts' define la entidad de usuario |
| 'core/application' | Define casos de uso, DTOs, mappers y queries | 'RegisterUserUC.ts' implementa la lógica de registro de usuario |
| 'modules' | Agrupa módulos específicos del negocio | 'user_management/' encapsula toda la gestión de usuarios |
| 'domain/entities' | Modelos de negocio que representan objetos persistentes | 'UserEnt.ts' representa los datos del usuario en la base de datos |
| 'domain/value_objects' | Objetos de valor inmutables del dominio | 'EmailVO.ts' maneja la lógica del correo electrónico |
| 'domain/repositories' | Interfaces para acceso a datos | 'UserRepo.ts' define la interfaz para obtener usuarios |
| 'application/use_cases' | Casos de uso que orquestan la lógica de negocio | 'RegisterUserUC.ts' contiene la lógica de registro de usuario |
| 'application/dtos' | Transporte de datos entre capas | 'UserDTO.ts' define el formato de los datos de usuario |
| 'application/mappers' | Conversión entre entidades y DTOs | 'UserMap.ts' convierte entre 'UserEnt' y 'UserDTO' |
| 'infrastructure/persistence' | Implementación de acceso a datos | 'UserRepoImpl.ts' implementa 'UserRepo.ts' |
| 'adapters/api' | Exposición de funcionalidades vía API | 'UserCtrl.ts' maneja solicitudes HTTP |
| 'tests' | Contiene pruebas unitarias y de integración | 'UserSvcTest.ts' prueba 'RegisterUserUC.ts' |
| 'docs' | Documentación técnica del proyecto | Documentación sobre el flujo de autenticación |
| 'config' | Configuración general de la aplicación | Configuración de base de datos y variables de entorno |
| 'scripts' | Scripts de automatización | Scripts para migraciones o configuración |
Implementar una estructura de carpetas bien organizada ayuda a crear proyectos más mantenibles, escalables y fáciles de entender. Al separar claramente las responsabilidades, evitamos el acoplamiento innecesario y mejoramos la colaboración dentro del equipo de desarrollo. Desde el inicio del proyecto, es recomendable definir y documentar la estructura de carpetas para que todo el equipo la adopte y mantenga. Con esta estructura clara y modular, los proyectos de software pueden escalar sin comprometer la calidad del código. 🚀
Convención de Nombres en Clases Java: Mejora de Legibilidad y Mantenibilidad
- Mauricio ECR
- Convenciones
- 18 Mar, 2024
En proyectos de gran escala, identificar rápidamente el tipo de una clase facilita la comprensión del código, la colaboración y la depuración. Sin un esquema de nombres estandarizado, es común que los
Convención de Nombres en Clases Java: Mejora de Legibilidad y Mantenibilidad
- Mauricio ECR
- Convenciones
- 18 Mar, 2024
En proyectos de gran escala, identificar rápidamente el tipo de una clase facilita la comprensión del código, la colaboración y la depuración. Sin un esquema de nombres estandarizado, es común que los desarrolladores enfrenten dificultades al interpretar la funcionalidad de una clase solo por su nombre, lo que ralentiza el mantenimiento y el desarrollo. En entornos profesionales, donde múltiples equipos trabajan sobre el mismo código base, contar con una nomenclatura clara mejora la comunicación y reduce la posibilidad de errores. Para solucionar esto, se propone una convención de nombres basada en prefijos y sufijos que permita diferenciar los distintos tipos de clases en Java.
Para garantizar la claridad, adoptamos las siguientes reglas:
- Prefijos:
- Enumeraciones: E (Ejemplo: EUserRole)
- Interfaces: I (Ejemplo: IUserService)
- Sufijos:
- Entities (Ent): Representan objetos persistentes. Ejemplo: UserEnt
- Value Objects (VO): Objetos inmutables con valor significativo. Ejemplo: MoneyVO
- Data Transfer Objects (DTO): Transporte de datos entre capas. Ejemplo: UserDTO
- Repositories (Repo): Acceso a datos. Ejemplo: UserRepo
- Services (Svc): Lógica de negocio. Ejemplo: UserSvc
- Controllers (Ctrl): Gestionan solicitudes HTTP. Ejemplo: UserCtrl
- Exceptions (Ex): Manejo de errores. Ejemplo: InvalidUserEx
- Configuration (Cfg): Configuraciones de aplicación. Ejemplo: DatabaseCfg
- Utility (Util): Métodos reutilizables. Ejemplo: DateUtil
- Test (Test): Clases de prueba. Ejemplo: UserSvcTest
- Commands (Cmd): Acciones en el sistema. Ejemplo: CreateUserCmd
- Domain Events (Evt): Eventos de dominio. Ejemplo: UserCreatedEvt
- Validation (Val): Validaciones. Ejemplo: UserVal
- Adapters (Adp): Integraciones con otros sistemas. Ejemplo: ExternalServiceAdp
- Message Brokers (Brk): Comunicación con colas de mensajes. Ejemplo: KafkaMsgBrk
- Mappers (Map): Transformación de objetos. Ejemplo: UserMap
- Views (View): Modelos de presentación. Ejemplo: UserView
- Gateways (Gtw): Comunicación con servicios externos. Ejemplo: PaymentGtw
- Factories (Fct): Creación de objetos. Ejemplo: UserFct
- Specifications (Spec): Criterios de búsqueda. Ejemplo: UserSpec
- Middleware (Mid): Interceptores de solicitudes. Ejemplo: AuthMid
- Aggregates (Agg): Raíces de agregados DDD. Ejemplo: OrderAgg
- Use Cases (UC): Casos de uso específicos. Ejemplo: RegisterUserUC
- Queries (Qry): Consultas a la base de datos. Ejemplo: FindUserQry
- View Models (VM): Modelos de datos para UI. Ejemplo: UserVM
- Policy / Guard (Pol o Grd): Reglas de negocio. Ejemplo: UserAccessPol
Para aplicar esta convención en un proyecto, recomendamos documentarla dentro del repositorio, integrar validaciones automáticas en las revisiones de código mediante linters o revisiones de PR y aplicar la convención en nuevos desarrollos, refactorizando el código legado progresivamente.
Adoptar una convención de nombres en clases Java mejora la comprensión del código y la colaboración en equipos de desarrollo. Al estandarizar prefijos y sufijos, cada clase tiene un propósito claro y es más fácil de ubicar y utilizar. Implementar esta convención desde el inicio de un proyecto o refactorizar el código existente progresivamente traerá beneficios a largo plazo.
| Tipo de Clase | Prefijo/Sufijo | Ejemplo | Descripción |
|---|---|---|---|
| Enumeraciones | E | EUserRole | Define un conjunto de valores constantes |
| Interfaces | I | IUserService | Define contratos que deben implementar las clases |
| Entities | Ent | UserEnt | Representa objetos persistentes en la base de datos |
| Value Objects | VO | MoneyVO | Objetos inmutables que representan valores |
| DTOs | DTO | UserDTO | Transporte de datos entre capas |
| Repositories | Repo | UserRepo | Acceso a la base de datos |
| Services | Svc | UserSvc | Contiene la lógica de negocio |
| Controllers | Ctrl | UserCtrl | Gestiona las solicitudes HTTP |
| Exceptions | Ex | InvalidUserEx | Manejo de errores y excepciones |
| Configuration | Cfg | DatabaseCfg | Configuraciones de la aplicación |
| Utilities | Util | DateUtil | Métodos reutilizables |
| Tests | Test | UserSvcTest | Pruebas unitarias y de integración |
| Commands | Cmd | CreateUserCmd | Representa una acción o comando |
| Domain Events | Evt | UserCreatedEvt | Eventos del dominio |
| Validation | Val | UserVal | Validaciones de datos |
| Adapters | Adp | ExternalServiceAdp | Integración con sistemas externos |
| Message Brokers | Brk | KafkaMsgBrk | Comunicación con colas de mensajes |
| Mappers | Map | UserMap | Conversión entre objetos |
| Views | View | UserView | Modelos de presentación |
| Gateways | Gtw | PaymentGtw | Comunicación con servicios externos |
| Factories | Fct | UserFct | Creación de instancias de objetos |
| Specifications | Spec | UserSpec | Definición de criterios de búsqueda |
| Middleware | Mid | AuthMid | Interceptores de solicitudes |
| Aggregates | Agg | OrderAgg | Raíz de un agregado en DDD |
| Use Cases | UC | RegisterUserUC | Casos de uso específicos |
| Queries | Qry | FindUserQry | Consultas a la base de datos |
| View Models | VM | UserVM | Modelos de datos para UI |
| Policy/Guard | Pol/Grd | UserAccessPol | Reglas de negocio y restricciones |