Tags (71)
- Agile
- Alta disponibilidad
- Alternativas cloud
- Aop
- Arquitectura
- Arquitectura distribuida
- Automatizacion
- Azure devops
- Base de datos
- Buenas practicas
- Cloud
- Colas
- Competing consumers
- Convenciones
- Copilot
- Diseno
- Docker
- Docker compose
- Documentacion
- Eda
- Equipos
- Escalabilidad
- Flujo de negocio
- Flujo de trabajo
- Flyway
- Git
- Gradle
- Herramientas digitales
- Ia
- Iam
- Infraestructura
- Java
- Jerarquia tecnica
- Jpa
- Jsonb
- Kafka
- Kubernetes
- Liderazgo en software
- Lineamientos
- Log
- Logging
- Microservicios
- Mongodb
- Monitoreo
- Nosql
- Observabilidad
- Open source
- Plugins
- Postgresql
- Privacidad
- Programacion funcional
- Programacion reactiva
- Rabbitmq
- Rotacion de talento
- Saga
- Scrum
- Security
- Seguridad
- Self hosting
- Sistemas legados
- Spring boot
- Spring mvc
- Sql
- Streams
- Threadlocal
- Trazabilidad
- Versionado
- Web
- Webflux
- Websockets
- Zero trust
Arquitectura de Confianza Cero en Docker Compose: Estrategias de Aislamiento y Segmentación de Redes
- Mauricio ECR
- DevOps
- 10 Jan, 2026
Docker Compose ha democratizado el despliegue de aplicaciones gracias a una premisa seductora: la simplicidad. Con un archivo YAML y un comando, servicios complejos cobran vida, interconectados y list
Arquitectura de Confianza Cero en Docker Compose: Estrategias de Aislamiento y Segmentación de Redes
- Mauricio ECR
- DevOps
- 10 Jan, 2026
Docker Compose ha democratizado el despliegue de aplicaciones gracias a una premisa seductora: la simplicidad. Con un archivo YAML y un comando, servicios complejos cobran vida, interconectados y listos para operar. Sin embargo, esta abstracción, que es su mayor virtud para la productividad, suele convertirse en su talón de Aquiles en términos de seguridad. Por defecto, Docker Compose crea una red default donde todos los contenedores pueden comunicarse libremente entre sí. En este escenario, si no se define una arquitectura de red explícita, se genera un entorno de confianza total implícita: el frontend tiene línea directa con la base de datos, y servicios auxiliares pueden ver componentes críticos sin necesidad alguna.
Si bien al inicio del desarrollo esto facilita la integración, en producción plantea una vulnerabilidad crítica conocida como movimiento lateral. La pregunta que debemos hacernos no es si el sistema funciona, sino: en caso de que un componente periférico sea comprometido, ¿qué alcance tendría el atacante? En la mayoría de las configuraciones estándar, la respuesta es alarmante: acceso total a la red interna del stack.
La arquitectura que exploraremos a continuación propone desmantelar esa confianza implícita. Adoptaremos un enfoque de Zero Trust (Confianza Cero) aplicado a la orquestación de contenedores, donde ningún servicio tiene permiso de comunicación por defecto y cada ruta de red debe estar justificada por una necesidad funcional estricta.
El Principio de la Puerta Única: Centralización del Acceso
Para visualizar la seguridad perimetral, podemos imaginar la infraestructura como una fortaleza. En un diseño ingenuo o descuidado, cada torre (servicio) tendría su propia puerta hacia el exterior, multiplicando los puntos de entrada y dificultando la vigilancia. La primera decisión de arquitectura segura es clausurar todos esos accesos directos y establecer un único punto de entrada, fuertemente vigilado.
En el ecosistema de contenedores, este rol lo desempeña el Proxy Inverso.
Este componente actúa como la única entidad autorizada para interactuar con la red pública (Internet). Reside en una red externa ("public") y su única función es recibir el tráfico, validarlo (potencialmente gestionando SSL/TLS) y enrutarlo hacia el interior. Desde la perspectiva externa, no existen bases de datos, ni backends, ni APIs internas; solo existe el proxy. Esta reducción de la superficie de ataque es fundamental: aunque un atacante escanee el host, solo encontrará los puertos estrictamente necesarios (80/443) abiertos por un servicio diseñado específicamente para manejar tráfico hostil.
Aislamiento Horizontal: Ciudades Amuralladas
Al escalar la infraestructura, es común alojar múltiples aplicaciones (o "stacks") en el mismo host. Un error frecuente es permitir que estos stacks compartan redes globales, lo que crearía "carreteras" invisibles entre proyectos que no tienen relación entre sí.
Para mitigar esto, debemos tratar cada aplicación como una "ciudad independiente". Aunque compartan el mismo territorio físico (el servidor host), no deben existir caminos directos entre ellas. Docker Compose facilita este aislamiento mediante el concepto de Namespacing en las redes. Al definir redes específicas dentro de cada docker-compose.yml, el orquestador prefija los nombres de red con el nombre del proyecto. Esto garantiza que la red interna del "Proyecto A" sea criptográficamente distinta e inaccesible para el "Proyecto B", asegurando que un compromiso en una aplicación no se propague horizontalmente a otras vecinas.
Segmentación Vertical: Barrios con Acceso Controlado
Dentro de cada "ciudad" o stack, la seguridad debe ser granular. No basta con estar dentro del perímetro para ser confiable. Aquí aplicamos una arquitectura de capas, dividiendo la aplicación según la sensibilidad de los datos y la función de los componentes: Frontend (recepción), Backend (lógica) y Datos (bóveda).
La Recepción: El Frontend y la Red Pública
El servicio de frontend es el único que mantiene contacto con el Proxy. Su ubicación es estratégica: tiene un pie en la red pública (para recibir tráfico del proxy) y un pie en una red privada interna para comunicarse con el backend. Sin embargo, carece totalmente de acceso a la capa de datos. Si un atacante lograra vulnerar el frontend, se encontraría en un callejón sin salida respecto a la base de datos, ya que no existe una ruta de red física (virtualizada) para alcanzarla.
La Sala de Control: El Backend como Intermediario
El backend opera en una zona de penumbra controlada. No es accesible desde internet, no expone puertos al host y solo acepta tráfico proveniente de la red front-back. A su vez, es el único componente autorizado para iniciar conexiones hacia la red back-db. Actúa como un guardia de lógica de negocio, validando cada petición antes de solicitar información a la capa de persistencia.
La Bóveda: Aislamiento de la Base de Datos
La base de datos reside en la zona más profunda de la arquitectura. Su aislamiento es tal que ni el proxy ni el frontend saben de su existencia; la resolución de nombres DNS de Docker ni siquiera les permitirá resolver su dirección IP. Además, para elevar el nivel de seguridad, es recomendable configurar las redes de datos con la propiedad internal: true. Esta directiva de Docker impide que los contenedores en esa red tengan acceso a la puerta de enlace predeterminada hacia internet, evitando que un posible malware en la base de datos pueda "llamar a casa" o exfiltrar datos hacia el exterior.
Implementación Técnica: La Configuración como Documentación
La teoría de la segmentación cobra sentido cuando se materializa en código. A continuación, se presenta una implementación de referencia que orquesta un proxy global y dos aplicaciones (stacks) totalmente aisladas entre sí, demostrando cómo la topología de red define las políticas de seguridad.
1. El Stack del Proxy: La Frontera
Este servicio define la red pública compartida. Su configuración es minimalista, enfocada únicamente en la exposición de puertos.
version: "3.9"
services:
proxy:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
networks:
- public
# En un escenario real, aquí se montarían volúmenes para certificados y configuración.
networks:
public:
name: public_gateway # Nombre explícito para que otros stacks la encuentren
2. Stack A: Aplicación con Arquitectura de Tres Capas
Este archivo demuestra la segmentación interna. Nótese cómo frontend, backend y db nunca comparten una red común los tres a la vez. La comunicación es estrictamente transitiva.
version: "3.9"
services:
frontend:
image: nginx:alpine
networks:
- public # Para hablar con el Proxy
- front_back # Para hablar con el Backend
depends_on:
- backend
backend:
image: node:18-alpine
networks:
- front_back # Recibe del Frontend
- back_db # Consulta a la DB
# No expone puertos al host. Su seguridad radica en su invisibilidad externa.
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: example_secure_password
networks:
- back_db # Solo accesible por el Backend
networks:
public:
external: true # Se conecta a la red creada por el Proxy
name: public_gateway
front_back:
internal: true # Opcional: restringe salida a internet si no se requieren APIs externas
back_db:
internal: true # Crítico: La DB no necesita acceso a internet
3. Stack B: Aislamiento por Diseño
Al replicar la estructura para una segunda aplicación, Docker Compose garantiza el aislamiento. Aunque los nombres de las redes internas (front_back, back_db) sean idénticos en el archivo YAML, el motor de Docker les asigna identificadores únicos basados en el proyecto.
version: "3.9"
services:
# La estructura es idéntica, pero el contexto de ejecución es estanco.
frontend:
image: nginx:alpine
networks:
- public
- front_back
backend:
image: python:3.10-alpine
networks:
- front_back
- back_db
db:
image: redis:alpine
networks:
- back_db
networks:
public:
external: true
name: public_gateway
front_back:
internal: true
back_db:
internal: true
En este esquema, el backend del Stack A intentando resolver el DNS db obtendrá la IP de su propia base de datos Postgres, y jamás la del Redis del Stack B. No hay posibilidad de colisión ni de acceso cruzado accidental.
Conclusiones y Horizonte de Evolución
El diseño de red en Docker Compose no debe considerarse una tarea de configuración trivial, sino la base fundacional de la seguridad de la aplicación. Al pasar de una red plana por defecto a una topología segmentada, logramos:
- Reducción de la Superficie de Ataque: Limitamos drásticamente qué contenedores son accesibles desde el exterior.
- Contención de Daños: Un servicio comprometido queda atrapado en su segmento de red, protegiendo la integridad de la base de datos y de otros stacks vecinos.
- Claridad Operativa: La arquitectura de red documenta por sí misma el flujo de datos de la aplicación.
Este modelo establece una base sólida sobre la cual construir medidas de seguridad más avanzadas. Como líneas futuras de mejora, esta arquitectura prepara el terreno para implementar mTLS (Mutual TLS) entre contenedores, asegurando no solo que la red sea la correcta, sino que el servicio interlocutor sea quien dice ser mediante certificados criptográficos. Asimismo, facilita la integración de herramientas de escaneo de vulnerabilidades y la gestión de secretos (Docker Secrets), cerrando el círculo de una estrategia de defensa en profundidad moderna y resiliente.
codigo mermaid
graph TB
subgraph Internet["🌐 Internet"]
EXT[External Users]
end
subgraph PublicNetwork["Public Network (bridge)"]
PROXY[("🔀 Reverse Proxy<br/>(NGINX/Traefik)")]
end
subgraph StackA["Stack A"]
subgraph NetworkFB_A["Network: front-back-a"]
FRONT_A["📱 Front A"]
BACK_A["⚙️ Back A"]
end
subgraph NetworkBD_A["Network: back-db-a"]
BACK_A2["⚙️ Back A"]
DB_A[("💾 DB A")]
end
end
subgraph StackB["Stack B"]
subgraph NetworkFB_B["Network: front-back-b"]
FRONT_B["📱 Front B"]
BACK_B["⚙️ Back B"]
end
subgraph NetworkBD_B["Network: back-db-b"]
BACK_B2["⚙️ Back B"]
DB_B[("💾 DB B")]
end
end
subgraph StackN["Stack N"]
subgraph NetworkFB_N["Network: front-back-n"]
FRONT_N["📱 Front N"]
BACK_N["⚙️ Back N"]
end
subgraph NetworkBD_N["Network: back-db-n"]
BACK_N2["⚙️ Back N"]
DB_N[("💾 DB N")]
end
end
%% Conexiones permitidas
EXT -.->|HTTPS| PROXY
PROXY -->|"✓ Acceso permitido"| FRONT_A
PROXY -->|"✓ Acceso permitido"| FRONT_B
PROXY -->|"✓ Acceso permitido"| FRONT_N
FRONT_A -->|"✓ API calls"| BACK_A
BACK_A2 -->|"✓ Queries"| DB_A
FRONT_B -->|"✓ API calls"| BACK_B
BACK_B2 -->|"✓ Queries"| DB_B
FRONT_N -->|"✓ API calls"| BACK_N
BACK_N2 -->|"✓ Queries"| DB_N
%% Estilos
classDef publicNet fill:#e1f5ff,stroke:#01579b,stroke-width:3px
classDef stackBox fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
classDef networkBox fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef proxy fill:#4fc3f7,stroke:#01579b,stroke-width:3px,color:#000
classDef front fill:#81c784,stroke:#2e7d32,stroke-width:2px,color:#000
classDef back fill:#ffb74d,stroke:#e65100,stroke-width:2px,color:#000
classDef db fill:#e57373,stroke:#c62828,stroke-width:2px,color:#000
classDef internet fill:#bbdefb,stroke:#1976d2,stroke-width:2px
class PublicNetwork publicNet
class StackA,StackB,StackN stackBox
class NetworkFB_A,NetworkBD_A,NetworkFB_B,NetworkBD_B,NetworkFB_N,NetworkBD_N networkBox
class PROXY proxy
class FRONT_A,FRONT_B,FRONT_N front
class BACK_A,BACK_A2,BACK_B,BACK_B2,BACK_N,BACK_N2 back
class DB_A,DB_B,DB_N db
class Internet,EXT internet
Entrega de Valor en Arquitecturas de Microservicios. Una reinterpretación pragmática de Scrum para sistemas distribuidos
- Mauricio ECR
- Gestion
- 27 Dec, 2025
Durante años, las metodologías ágiles —y Scrum en particular— han demostrado ser eficaces para organizar el trabajo y acelerar la entrega de valor. Sin embargo, muchas de sus prácticas nacieron en un
Entrega de Valor en Arquitecturas de Microservicios. Una reinterpretación pragmática de Scrum para sistemas distribuidos
- Mauricio ECR
- Gestion
- 27 Dec, 2025
Durante años, las metodologías ágiles —y Scrum en particular— han demostrado ser eficaces para organizar el trabajo y acelerar la entrega de valor. Sin embargo, muchas de sus prácticas nacieron en un contexto tecnológico muy distinto al actual: aplicaciones monolíticas, equipos pequeños y despliegues centralizados. En ese escenario, asumir que una historia de usuario equivalía a una funcionalidad completa y visible para el usuario final era no solo razonable, sino práctico.
Hoy, esa premisa se pone en tensión cuando las organizaciones adoptan arquitecturas de microservicios con frontend desacoplado. La funcionalidad de negocio ya no reside en un único lugar ni se entrega como una sola pieza. Se construye de forma distribuida, a través de múltiples servicios, repositorios y equipos que avanzan a ritmos distintos. Este documento nace precisamente para abordar esa fricción: cómo seguir siendo ágiles sin ignorar la realidad técnica, y cómo reconocer valor allí donde tradicionalmente no se ha sabido mirar.
El objetivo no es redefinir Scrum ni entrar en debates dogmáticos, sino establecer una base clara y compartida que permita gestionar el ciclo de vida del desarrollo de software de forma coherente, predecible y sostenible en entornos distribuidos.
El problema de fondo: cuando una historia ya no contiene toda la funcionalidad
En una aplicación monolítica, la relación entre código, despliegue y experiencia de usuario era directa. Una historia de usuario solía implicar cambios en la interfaz, la lógica de negocio y la base de datos, todo dentro del mismo repositorio y liberado al mismo tiempo. El resultado era una funcionalidad tangible y visible para el usuario final.
Aplicación Monolítica:
┌────────────────────────────────────────┐
│ Una Historia = Una Funcionalidad │
│ Visible en Pantalla │
│ │
│ Código UI + Lógica + BD en mismo repo │
│ Desplegado todo junto │
└────────────────────────────────────────┘
Al pasar a una arquitectura de microservicios, esta equivalencia se rompe. Una misma funcionalidad de negocio depende ahora de un frontend independiente, varios servicios backend y, en muchos casos, integraciones externas. Cada uno de estos elementos tiene su propio ciclo de desarrollo, su propio backlog y su propia cadencia de despliegue.
Arquitectura Distribuida:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Frontend │─────▶│ Backend 1 │─────▶│ Backend 2 │
│ (Repo A) │ │ (Repo B) │ │ (Repo C) │
│ Deploy 1 │ │ Deploy 2 │ │ Deploy 3 │
└─────────────┘ └─────────────┘ └─────────────┘
▲ ▲ ▲
│ │ │
3 equipos 3 backlogs 3 velocidades
Insistir en que cada historia de usuario debe representar valor inmediato y visible para el usuario final genera consecuencias previsibles: bloqueos entre equipos, dependencia constante de integraciones y una percepción distorsionada de la velocidad real. El trabajo backend queda “invisible”, la calidad técnica se resiente y la entrega se vuelve errática.
La pregunta incómoda: ¿una API puede tener valor?
Uno de los puntos de fricción más habituales aparece cuando se cuestiona si una API backend, por sí sola, puede considerarse valor. Desde una interpretación estricta de Scrum, la respuesta suele ser negativa: si el usuario final no puede interactuar con ella, no hay valor entregado.
El problema de esta visión es que ignora cómo se construyen realmente los sistemas complejos. En un entorno distribuido, el valor no aparece de golpe al final del proceso, sino que se va materializando en forma de activos técnicos que reducen incertidumbre, habilitan trabajo paralelo y evitan retrabajo futuro.
De este modo, es necesario distinguir entre dos tipos de valor que coexisten en el flujo de entrega. Por un lado, el valor de negocio directo, que se manifiesta cuando una funcionalidad completa está integrada de extremo a extremo y disponible para el usuario final. Por otro, el valor de activo técnico, que se entrega cuando un componente queda listo, probado y certificado para ser usado por otros equipos.
Timeline de Entrega de Valor:
Día 1-3: Habilitadores completos
└─ Valor: equipos desbloqueados
Día 4-6: HU Backend completa
└─ Valor de ACTIVO: API certificada
Día 5-7: HU Frontend completa
└─ Valor de ACTIVO: UI certificada
Día 8-9: Integración E2E en QA
└─ Valor de NEGOCIO
Día 10: Producción
└─ Valor capturado por usuarios
Reconocer este segundo tipo de valor no implica bajar el estándar, sino hacerlo explícito. Una API bien diseñada y validada antes de la integración final reduce riesgos, acelera la entrega y mejora la calidad global del sistema.
Una analogía necesaria para entender el enfoque
La lógica detrás de este modelo resulta más evidente cuando se traslada al mundo físico. En una fábrica de automóviles, el motor, la transmisión y el chasis se construyen por separado. Cada uno de estos componentes tiene valor una vez que ha sido fabricado y probado, incluso aunque el coche completo todavía no exista.
Nadie consideraría desperdicio construir un motor certificado solo porque el vehículo aún no puede circular. De la misma manera, una API backend validada y desplegada en un entorno de pruebas tiene valor real, aunque el usuario final todavía no la vea. Ese valor reside en su capacidad de integrarse sin sorpresas y de servir como base sólida para el resto del sistema.
Cómo se traduce este modelo al desarrollo con microservicios
Cuando este razonamiento se aplica al desarrollo de software, la estructura se vuelve más clara. Una feature representa el objetivo de negocio final, pero su construcción requiere distintos niveles de trabajo que no pueden mezclarse sin generar confusión.
Primero aparecen los habilitadores, como la definición del contrato de la API o la configuración de la infraestructura. Estos elementos no tienen narrativa de usuario final, pero son imprescindibles para que el desarrollo avance sin bloqueos. A continuación, las historias de usuario de backend y frontend entregan componentes certificados de forma independiente. Solo cuando estos componentes se integran se materializa el valor de negocio directo.
FEATURE: "Transferir dinero entre cuentas"
Habilitador:
- Contrato OpenAPI definido y validado
HU Backend:
- API desplegada en QA
- Tests de contrato pasando
- Lista para consumo inmediato
HU Frontend:
- UI validada contra stubs
- Flujos de error implementados
Feature Completa:
- Integración E2E certificada
- Funcionalidad disponible en producción
Este enfoque no fragmenta la entrega; la hace explícita. Cada equipo sabe qué está entregando, para quién y con qué criterio de calidad.
Por qué esto no es waterfall encubierto
Una crítica frecuente es que esta separación recuerda a un modelo en cascada. La diferencia fundamental es que aquí no existen fases bloqueantes. Frontend y backend trabajan en paralelo, desacoplados mediante contratos y stubs, obteniendo feedback temprano y continuo.
En un modelo waterfall, los equipos esperan a que otros terminen para empezar. En este enfoque, los componentes se certifican de forma independiente y la integración se convierte en un paso predecible, no en un cuello de botella tardío.
Un concepto de usuario más amplio
Otro cambio clave es la evolución del concepto de “usuario”. En arquitecturas modernas, no solo las personas consumen funcionalidades. También lo hacen otros sistemas y los desarrolladores que integran servicios.
Una pasarela de pagos como Stripe lo ilustra con claridad: tiene compradores finales, aplicaciones que consumen la API y desarrolladores que necesitan documentación clara para integrar el servicio. Cada uno de ellos recibe valor distinto, y todos son usuarios legítimos. Ignorar alguno de estos niveles degrada el producto y ralentiza su adopción.
Cuando el dogma supera a la realidad
La experiencia demuestra que aplicar Scrum de forma rígida en sistemas distribuidos suele generar efectos contraproducentes. Organizaciones que solo reconocen valor cuando el cliente final ve algo en pantalla tienden a invisibilizar el trabajo técnico, acumular deuda y sufrir bloqueos constantes.
En contraste, aquellas que distinguen claramente entre valor de activo y valor de negocio logran reducir defectos, mejorar la previsibilidad y aumentar la motivación de los equipos. Reconocer el valor técnico no es una concesión, sino una condición necesaria para la sostenibilidad.
Niveles de trabajo como marco común
Para evitar ambigüedades, este enfoque distingue cuatro niveles de trabajo: Feature, Habilitador, Historia de Usuario y Tarea. Cada uno cumple una función específica y tiene criterios de finalización distintos, lo que permite planificar y medir el progreso sin mezclar expectativas.
Relación jerárquica:
1 Feature
├─ Habilitadores
├─ Historias de Usuario (Frontend / Backend)
│ └─ Tareas técnicas
Esta jerarquía no añade burocracia; añade claridad. Permite que cada equipo sepa cuándo su trabajo está realmente terminado y cómo contribuye al objetivo global.
Conclusión
La adopción de arquitecturas de microservicios exige una reinterpretación pragmática de los principios ágiles. El valor ya no aparece en un único momento ni en una sola capa del sistema. Se construye progresivamente, a través de activos técnicos certificados que habilitan la entrega final de valor de negocio.
Este documento establece una base para trabajar con esa realidad, permitiendo desacoplar equipos, visibilizar el trabajo técnico y mejorar la calidad global del sistema. A partir de aquí, el marco puede evolucionar hacia métricas más avanzadas, automatización de certificaciones y una gestión aún más madura de dependencias e integraciones.
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC
- Mauricio ECR
- Snippets
- 24 Sep, 2025
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC Imagina a un desarrollador frontend consumiendo tu API. En un endpoint, recibe un objeto JSON. En otro, una simple lista. S
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC
- Mauricio ECR
- Snippets
- 24 Sep, 2025
El Arte de la Consistencia: Creando Respuestas API Predecibles en Spring MVC Imagina a un desarrollador frontend consumiendo tu API. En un endpoint, recibe un objeto JSON. En otro, una simple lista. Si ocurre un error de validación, obtiene una estructura compleja; si el servidor falla, recibe un texto plano. Cada variación, por pequeña que sea, introduce una nueva lógica condicional en el cliente. Rápidamente, esa falta de estándar se convierte en un caos silencioso, una deuda técnica que frena la innovación y fragiliza el sistema.
La estandarización de las respuestas de una API no es una cuestión de estética, sino una decisión de arquitectura fundamental. El verdadero desafío es cómo lograr esta uniformidad sin contaminar nuestra lógica de negocio con código repetitivo. Afortunadamente, Spring MVC nos ofrece herramientas de una elegancia sorprendente, @RestControllerAdvice y ResponseBodyAdvice, diseñadas precisamente para resolver estos problemas transversales de forma limpia y centralizada.
Este artículo te guiará en la implementación de un patrón de respuesta robusto y unificado en un entorno Spring Boot con Lombok, cubriendo tanto los casos de éxito como los de error de manera consistente y profesional.
El Contrato: La Piedra Angular de la Previsibilidad
Antes de escribir una sola línea de lógica, debemos definir nuestro objetivo: un formato de respuesta único que sirva tanto para éxitos como para errores. Esta es la base de la predictibilidad. En lugar de improvisar, diseñaremos una estructura genérica que actúe como un contrato inmutable con nuestros clientes.
La clave de nuestra estrategia es la clase ApiResponse. Este DTO (Data Transfer Object) genérico contendrá tres componentes principales:
meta: Un objeto con metadatos de la solicitud (timestamp, ID de la petición, etc.), útil para la depuración y el monitoreo.data: El payload real de la respuesta en caso de éxito. Será de tipo genérico (T).errors: Una lista de errores detallados si algo sale mal.
Una de las decisiones más importantes aquí es el uso de la anotación @JsonInclude(JsonInclude.Include.NON_NULL). Esta simple línea le indica a Jackson (el serializador JSON de Spring) que omita cualquier campo con valor nulo. ¿El resultado? Las respuestas exitosas no tendrán el campo errors y las de error no tendrán el campo data, manteniendo así los JSON limpios y relevantes sin necesidad de crear múltiples clases.
Para construir este contrato, asegúrate de tener las dependencias esenciales en tu pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Y aquí está el diseño de nuestro contrato unificado:
// src/main/java/com/example/demo/common/ApiResponse.java
package com.example.demo.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {
private Meta meta;
private T data;
private List<ErrorDetail> errors;
@Data
@Builder
public static class Meta {
private String timestamp = Instant.now().toString();
@Builder.Default
private String requestId = UUID.randomUUID().toString().substring(0, 10);
private String path;
private int status;
}
@Data
@Builder
public static class ErrorDetail {
private String code;
private String message;
}
public static <T> ApiResponse<T> success(T data, String path, int status) {
return ApiResponse.<T>builder()
.meta(Meta.builder().path(path).status(status).build())
.data(data)
.build();
}
public static ApiResponse<?> error(List<ErrorDetail> errors, String path, int status) {
return ApiResponse.builder()
.meta(Meta.builder().path(path).status(status).build())
.errors(errors)
.build();
}
}
La Arquitectura de la Consistencia: Separando Responsabilidades
Para una solución robusta y mantenible, aplicaremos el Principio de Responsabilidad Única. En lugar de una sola clase monolítica, dividiremos nuestra lógica en dos componentes especializados, ambos anotados con @RestControllerAdvice. Spring es lo suficientemente inteligente como para detectar y aplicar ambos.
Primero, crearemos una configuración para permitirnos habilitar, deshabilitar o excluir rutas de este comportamiento, dándonos flexibilidad para casos especiales como los endpoints de Actuator o Swagger.
// src/main/java/com/example/demo/common/ResponseWrapperProperties.java
package com.example.demo.common;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "api.response.wrapper")
public class ResponseWrapperProperties {
private boolean enabled = true;
private List<String> excludedPaths = new ArrayList<>();
}
1. El Guardián de Respuestas Exitosas
Nuestra primera clase, GlobalResponseHandler, tendrá una sola misión: interceptar las respuestas exitosas de los controladores y envolverlas en nuestra estructura ApiResponse. Utiliza la interfaz ResponseBodyAdvice para modificar el cuerpo de la respuesta justo antes de que se envíe.
// src/main/java/com/example/demo/common/GlobalResponseHandler.java
package com.example.demo.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalResponseHandler implements ResponseBodyAdvice<Object> {
private final ResponseWrapperProperties properties;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String path = servletRequest.getRequestURI();
// Si el cuerpo ya es un ApiResponse (creado por el manejador de excepciones)
// o la ruta está excluida, no hacemos nada.
if (body instanceof ApiResponse || isExcluded(path)) {
return body;
}
int status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
return ApiResponse.success(body, path, status);
}
private boolean isExcluded(String path) {
return !properties.isEnabled() || properties.getExcludedPaths().stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path));
}
}
2. El Centinela Central de Errores
La segunda clase, GlobalExceptionHandler, se dedicará exclusivamente a capturar excepciones lanzadas desde cualquier controlador. Usando @ExceptionHandler, las convierte en nuestra respuesta ApiResponse estandarizada. Este aislamiento hace que el código de manejo de errores sea fácil de encontrar, mantener y extender.
// src/main/java/com/example/demo/common/GlobalExceptionHandler.java
package com.example.demo.common;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<?> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) {
List<ApiResponse.ErrorDetail> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> ApiResponse.ErrorDetail.builder()
.code("VALIDATION_ERROR")
.message(String.format("'%s': %s", error.getField(), error.getDefaultMessage()))
.build())
.collect(Collectors.toList());
return ApiResponse.error(errors, request.getRequestURI(), HttpStatus.BAD_REQUEST.value());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleAllUncaughtException(Exception ex, HttpServletRequest request) {
log.error("Error no controlado en la ruta {}: {}", request.getRequestURI(), ex.getMessage(), ex);
ApiResponse.ErrorDetail error = ApiResponse.ErrorDetail.builder()
.code("INTERNAL_SERVER_ERROR")
.message("Ocurrió un error inesperado. Por favor, contacte al soporte.")
.build();
return ApiResponse.error(Collections.singletonList(error), request.getRequestURI(), HttpStatus.INTERNAL_SERVER_ERROR.value());
}
}
Ganando Confianza: Pruebas que Validan la Arquitectura
Probar componentes transversales es crucial. Con @WebMvcTest, creamos un contexto de prueba ligero que se enfoca en la capa web. La clave es importar ambas clases de Advice en nuestro test para asegurar que el comportamiento combinado (formateo de éxito y manejo de errores) se verifica correctamente.
// src/test/java/com/example/demo/common/GlobalResponseHandlerTest.java
package com.example.demo.common;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.AllArgsConstructor;
import lombok.Data;
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.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(controllers = GlobalResponseHandlerTest.TestController.class)
// Importamos AMBAS clases para que el contexto de prueba refleje la configuración real.
@Import({GlobalResponseHandler.class, GlobalExceptionHandler.class})
class GlobalResponseHandlerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ResponseWrapperProperties responseWrapperProperties;
// ... (El resto de la clase de prueba, incluyendo setUp, TestController, TestDto y los métodos de prueba, permanece igual) ...
@BeforeEach
void setUp() {
when(responseWrapperProperties.isEnabled()).thenReturn(true);
when(responseWrapperProperties.getExcludedPaths()).thenReturn(List.of("/excluded/**"));
}
@RestController
static class TestController {
@GetMapping("/test/success")
public TestDto getSuccess() { return new TestDto("ok"); }
@PostMapping("/test/validation")
public TestDto postValidation(@Valid @RequestBody TestDto dto) { return dto; }
@GetMapping("/excluded/path")
public TestDto getExcluded() { return new TestDto("excluded"); }
}
@Data
@AllArgsConstructor
static class TestDto {
@NotEmpty
private String message;
}
@Test
@DisplayName("Debería envolver una respuesta exitosa en el formato ApiResponse")
void shouldWrapSuccessResponse() throws Exception {
mockMvc.perform(get("/test/success"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta").exists())
.andExpect(jsonPath("$.data.message").value("ok"))
.andExpect(jsonPath("$.errors").doesNotExist());
}
@Test
@DisplayName("No debería envolver una respuesta si la ruta está excluida")
void shouldNotWrapExcludedPath() throws Exception {
mockMvc.perform(get("/excluded/path"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.meta").doesNotExist())
.andExpect(jsonPath("$.message").value("excluded"));
}
@Test
@DisplayName("Debería manejar un error de validación y devolver ApiResponse con detalles de error")
void shouldHandleValidationError() throws Exception {
String invalidDtoJson = "{\"message\":\"\"}";
mockMvc.perform(post("/test/validation")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidDtoJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.meta").exists())
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[0].code").value("VALIDATION_ERROR"));
}
}
Más Allá del Código: El Impacto de una Arquitectura Consistente
Hemos recorrido un camino que va más allá de un simple truco de código. Partimos de un problema real —el caos de las respuestas inconsistentes— y, en lugar de aplicar parches, diseñamos una solución arquitectónica limpia basada en la separación de responsabilidades.
El resultado es un patrón robusto y no invasivo que unifica todas las respuestas bajo un contrato predecible. La lógica está aislada, es configurable y completamente testeable. Esta inversión en diseño reduce la carga cognitiva para todos, desde los desarrolladores del backend hasta los consumidores de la API, creando sistemas más mantenibles, escalables y, en definitiva, más sencillos de razonar.
Este patrón no es un punto final, sino una base sólida. Las posibilidades futuras son claras:
- Documentación de API: El siguiente paso es asegurar que herramientas como OpenAPI/Swagger reflejen esta estructura
ApiResponseautomáticamente, proporcionando una documentación precisa del contrato real. - Trazabilidad Distribuida: El
requestIden los metadatos es la semilla para una trazabilidad completa. Integrarlo con herramientas como Micrometer Tracing permitiría seguir una petición a través de múltiples microservicios, simplificando la depuración en entornos complejos. - Observabilidad Mejorada: El bloque
metapuede enriquecerse con más datos, como el tiempo de procesamiento, para alimentar dashboards en herramientas como Grafana y Prometheus, ofreciendo una visión más profunda del rendimiento de la API.
El Arte del Contexto: Diseño Flexible en Arquitecturas DDD con Java y Spring Boot
- Mauricio ECR
- Snippets
- 23 Sep, 2025
En el universo del desarrollo de software empresarial, nos enfrentamos a un dilema constante: cómo manejar información transversal —ese rastro de datos vitales como IDs de correlación, información del
El Arte del Contexto: Diseño Flexible en Arquitecturas DDD con Java y Spring Boot
- Mauricio ECR
- Snippets
- 23 Sep, 2025
En el universo del desarrollo de software empresarial, nos enfrentamos a un dilema constante: cómo manejar información transversal —ese rastro de datos vitales como IDs de correlación, información del usuario o el tenant de un sistema multi-inquilino— sin que contamine la pureza de nuestro dominio. Estos datos son el sistema nervioso de la aplicación, esenciales para la trazabilidad, la auditoría y la seguridad, pero no son parte del lenguaje de negocio. Incluirlos como parámetros en cada método del dominio es una solución rápida que, a la larga, genera un código verboso y acoplado.
Imagina que estás construyendo un sistema complejo con Java 21, Spring Boot y una filosofía Domain-Driven Design (DDD). Tu arquitectura está elegantemente separada en capas de dominio, infraestructura y aplicación. ¿Cómo logras que ese "contexto" de ejecución fluya mágicamente a través de todas las capas, disponible cuando se necesita, pero invisible cuando no? El objetivo es crear un mecanismo que sea a la vez transparente y robusto, que funcione igual de bien para una petición REST, un proceso batch o un mensaje de una cola.
La encrucijada del diseño: ¿Parámetro o Magia?
Cuando nos enfrentamos a la propagación de contexto, hay dos caminos. El primero es el de la explicitud: si un dato es parte del lenguaje de negocio (como el "autor" de una acción), debe ser un parámetro explícito en el dominio. No hay discusión. El segundo camino es el de la transversalidad, reservado para metadatos puramente técnicos. Aquí es donde queremos un poco de "magia controlada", una forma de acceder a la información sin que ensucie nuestras interfaces de negocio.
Para esta magia, ThreadLocal se presenta como un candidato ideal en el ecosistema tradicional de Spring. Es, en esencia, una caja de almacenamiento que cada hilo de ejecución lleva consigo. Lo que un hilo guarda en su ThreadLocal, solo ese hilo puede verlo, garantizando un aislamiento perfecto en entornos concurrentes.
Nuestra herramienta será un ExecutionContext, un contenedor simple que vivirá en ese ThreadLocal. Usamos un Map<String, Object> en su interior por una razón clave: flexibilidad. Podríamos crear una clase con campos fijos (correlationId, userId, etc.), pero un mapa nos permite añadir nuevos datos al contexto en el futuro sin modificar la clase base. Es un compromiso consciente entre la seguridad de tipos y la extensibilidad.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Un contenedor simple y flexible para los datos de nuestro contexto.
public class ExecutionContext {
private static final ThreadLocal<ExecutionContext> CONTEXT = new ThreadLocal<>();
private final Map<String, Object> values = new ConcurrentHashMap<>();
// ... (métodos init, current, clear, put, get)
public static ExecutionContext current() { return CONTEXT.get(); }
public static void set(ExecutionContext context) { CONTEXT.set(context); }
public static void clear() { CONTEXT.remove(); }
public static ExecutionContext init() {
ExecutionContext ctx = new ExecutionContext();
set(ctx);
return ctx;
}
public void put(String key, Object value) { values.put(key, value); }
@SuppressWarnings("unchecked")
public <T> T get(String key, Class<T> type) { return (T) values.get(key); }
}
El guardián del ciclo de vida: Automatización con un Filter
Tener el ExecutionContext es solo el primer paso. El mayor riesgo de ThreadLocal es el olvido. En un servidor como Tomcat, los hilos se reciclan. Si no limpiamos el contexto al final de una petición, ese hilo reutilizado podría servir a otro usuario con los datos del anterior, una brecha de seguridad y de datos catastrófica.
Aquí es donde entra en juego el Filter de Servlet, el guardián de nuestro contexto. Un Filter es perfecto porque opera a un nivel más bajo que los controladores de Spring. Intercepta toda petición entrante, dándonos el lugar ideal para:
- Inicializar el contexto al empezar.
- Poblarlo con datos de la petición, como cabeceras.
- Garantizar su limpieza al terminar, pase lo que pase.
El bloque try...finally no es una opción, es una obligación. Es el seguro de vida que nos protege contra las fugas de memoria y la contaminación de datos. En el siguiente código, no solo capturamos un ID de correlación, sino también un token JWT, demostrando la flexibilidad del mapa.
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.logging.log4j.ThreadContext;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.UUID;
@Component
public class ExecutionContextFilter implements Filter {
// ... (constantes para las claves)
private static final String CORRELATION_ID_HEADER = "X-Correlation-ID";
private static final String AUTH_HEADER = "Authorization";
private static final String CORRELATION_ID_KEY = "correlationId";
private static final String JWT_KEY = "jwtToken";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
ExecutionContext.init(); // Nace el contexto
try {
// Se enriquece con datos de la petición
if (request instanceof HttpServletRequest httpRequest) {
String correlationId = httpRequest.getHeader(CORRELATION_ID_HEADER);
// ... (lógica para generar correlationId si no existe)
ExecutionContext.current().put(CORRELATION_ID_KEY, correlationId);
ThreadContext.put(CORRELATION_ID_KEY, correlationId); // Se lo pasamos a Log4j2
String token = httpRequest.getHeader(AUTH_HEADER);
if (token != null) {
ExecutionContext.current().put(JWT_KEY, token);
}
}
chain.doFilter(request, response);
} finally {
// Se limpia, garantizando que el hilo reciclado esté impoluto
ThreadContext.clearMap();
ExecutionContext.clear();
}
}
}
Observabilidad sin esfuerzo: El poder del Logging contextual
Depurar un problema en producción sin un ID de correlación es como buscar una aguja en un pajar. Al integrar nuestro contexto con el sistema de logging (vía el MDC de Log4j2, que es su propia versión de ThreadLocal), cada línea de log generada durante la petición quedará marcada con ese identificador único. De repente, el pajar se organiza en hilos de paja perfectamente trazables.
Solo necesitamos decirle a Log4j2 que muestre esa información en su patrón:
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - [%X{correlationId}] - %msg%n"/>
</Console>
</Appenders>
</Configuration>
Un ejemplo práctico para unirlo todo
La teoría está muy bien, pero veámoslo en acción. Imaginemos un flujo simple: un controlador recibe una petición, llama a un caso de uso que a su vez depende de un repositorio para hablar con un servicio externo.
El Dominio, un oasis de pureza:
El código del dominio no sabe nada del contexto. Define los contratos (HelloWorldRepository) y orquesta la lógica (GetHelloWorldUseCase), manteniéndose limpio y enfocado.
// Caso de Uso: depende de una abstracción y no sabe de dónde saldrán los datos.
public class GetHelloWorldUseCase {
private final HelloWorldRepository helloWorldRepository;
// ... (constructor)
public String execute() {
// ... (log de inicio)
String externalGreeting = helloWorldRepository.getGreeting();
// ¡Aquí es donde el dominio se beneficia de la "magia"!
// Accede al contexto de forma segura y opcional.
String correlationId = ExecutionContext.current().get("correlationId", String.class);
System.out.println("DESDE CASO DE USO: Mensaje del repo: '" + externalGreeting +
"'. Trazabilidad: " + correlationId);
return externalGreeting;
}
}
La Infraestructura, donde ocurre la magia:
Aquí es donde implementamos los detalles. Nuestro RestHelloWorldRepository simula ser un cliente REST. Antes de hacer su "llamada", consulta el ExecutionContext para obtener el token JWT que necesita para autenticarse. ¡El caso de uso nunca tuvo que pasárselo!
// Implementación del Repositorio: el "fontanero" que conecta con el mundo exterior.
public class RestHelloWorldRepository implements HelloWorldRepository {
@Override
public String getGreeting() {
// Obtiene el token que el Filter puso en el contexto.
String jwt = ExecutionContext.current().get("jwtToken", String.class);
System.out.println("DESDE REPOSITORIO (CLIENTE REST): Usando el token para la llamada -> " + jwt);
return "Hola desde el servicio externo!";
}
}
Al ejecutar una petición con curl que incluya las cabeceras X-Correlation-ID y Authorization, la salida en la consola nos cuenta la historia completa: el log con el ID, la implementación del repositorio usando el token, y el caso de uso accediendo de nuevo al ID. Todo fluyó sin que una sola firma de método se viera alterada.
Conclusión: Un patrón poderoso con responsabilidades
Hemos construido un sistema robusto para manejar el contexto. Sin embargo, este poder conlleva responsabilidades. El patrón ThreadLocal es una herramienta fantástica para arquitecturas síncronas basadas en el modelo "un hilo por petición", pero es el enfoque incorrecto para sistemas reactivos (como WebFlux), donde la ejecución no está atada a un único hilo. Allí, la solución nativa es el Context de Project Reactor.
Además, con la llegada de los Hilos Virtuales en Java, el uso masivo de ThreadLocal puede limitar sus beneficios de escalabilidad. El futuro apunta a los Scoped Values (JEP 446), diseñados precisamente para este tipo de propagación de datos de forma más eficiente.
Entender estas fronteras es tan importante como conocer el patrón en sí. La clave del éxito es siempre la misma: mantener el dominio puro y delegar las complejidades técnicas a una capa de infraestructura bien diseñada.
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.