API REST en Spring Boot que funciona como pasarela entre aplicaciones modernas (REST/JSON) y un sistema core legacy que solo expone servicios SOAP/XML. El cliente nunca ve XML: LegacyBridge autentica, valida, traduce la petición a SOAP, la envía al sistema legacy y guarda el estado de cada operación.
Es el problema típico del sector financiero: canales digitales nuevos que necesitan hablar con un core bancario antiguo sin acoplarse a su protocolo. El proyecto nace de mi experiencia profesional integrando el core bancario Bantotal mediante servicios SOAP en GeneXus consumidos desde Java (ver el caso de estudio de esa experiencia).
Java 17 · Spring Boot · SOAP / WSDL / XSD · XML (JAXB) · Spring-WS · REST · Spring Security / JWT · SQL Server · JUnit / Mockito
Proyecto personal de portafolio. No reproduce código, endpoints, WSDL, entidades ni reglas de negocio de ningún sistema empresarial real. Toda la integración corre contra un Mock Legacy System propio incluido en este repositorio.
Estado: funcional, con 30 tests automatizados pasando.
Cliente ──HTTP + REST/JSON + JWT──▶ LegacyBridge ──HTTP + SOAP/XML──▶ Sistema legacy
- Qué demuestra este proyecto
- Ejecución real (Postman)
- Arquitectura
- Integración con el sistema legacy
- Autenticación y autorización
- Endpoints
- Ejemplos de request/response
- Manejo de errores
- Decisiones de diseño
- Tecnologías
- Inicio rápido
- Configuración y SQL Server
- Tests
- Backend Java para integración con sistemas legacy: API en Java 17 y Spring Boot que expone un core legacy SOAP como una API REST moderna, el mismo tipo de integración que se hace con cores bancarios.
- Integración SOAP contract-first: contrato XSD, WSDL publicado, cliente con Spring-WS (
WebServiceTemplate) y mapeo objeto ↔ XML con JAXB. - Patrón Gateway: la lógica de negocio depende de una interfaz (
LegacyOperationGateway), nunca de SOAP. Cambiar el protocolo del legacy solo afecta a una clase. - Seguridad: Spring Security con JWT, roles
ROLE_USER/ROLE_ADMINcon@PreAuthorize, contraseñas con BCrypt. - Manejo de errores por capa: contrato único
ApiError, y 502 Bad Gateway cuando falla el sistema legacy. - Ciclo de vida de una operación:
PENDING → PROCESSING → COMPLETED | FAILED, persistido en SQL Server. - Tests de punta a punta: login → crear operación → ejecutar → SOAP real sobre HTTP contra el legacy simulado.
Capturas de una ejecución real contra SQL Server y el sistema legacy por SOAP. La colección usada está en postman/, lista para importar.
Llamada SOAP al sistema legacy: request y response en XML.
REST → SOAP de punta a punta: el cliente ejecuta la operación por REST, LegacyBridge la envía al legacy por SOAP y la operación queda COMPLETED con el id asignado por el legacy.
El legacy rechaza la operación (monto mayor a 100000) y queda FAILED:
Contrato WSDL publicado por el sistema legacy:
Seguridad por roles: un usuario sin ROLE_ADMIN recibe 403:
Validación de datos: 400 con el detalle de cada campo inválido:
Organización por feature: cada dominio es autocontenido.
com.legacybridge
├── operation
│ ├── controller -> API REST de operaciones
│ ├── dto -> contratos de entrada/salida (records)
│ ├── entity -> Operation, OperationStatus
│ ├── repository -> Spring Data JPA
│ └── service -> lógica de negocio y ciclo de vida
├── auth
│ ├── controller -> POST /api/v1/auth/login
│ ├── dto -> LoginRequest / LoginResponse
│ ├── entity -> User, Role
│ ├── repository -> UserRepository
│ ├── service -> AuthenticationService, UserDetailsServiceImpl
│ ├── security -> JwtService, JwtAuthenticationFilter
│ └── DemoUserSeeder (solo desarrollo)
├── legacy
│ ├── LegacyOperationGateway -> interfaz: frontera hacia el legacy
│ ├── SoapLegacyOperationGateway -> implementación SOAP (WebServiceTemplate)
│ ├── LegacyOperationResult -> resultado interno, sin nada de SOAP
│ ├── LegacyIntegrationException
│ ├── jaxb/ -> mensajes SOAP anotados con JAXB
│ └── mock/MockLegacyEndpoint -> sistema legacy simulado (@Endpoint)
├── common/exception -> manejo global de errores (@RestControllerAdvice)
└── config
├── SecurityConfig
├── OpenApiConfig
└── LegacySoapConfig -> cliente SOAP + servlet del legacy simulado
La entidad Operation nunca se expone directamente: los controllers solo hablan en DTOs, y OperationService solo conoce la interfaz LegacyOperationGateway, nunca clases JAXB ni Spring-WS.
sequenceDiagram
participant C as Cliente
participant OC as OperationController
participant OS as OperationService
participant GW as SoapLegacyOperationGateway
participant ML as Mock Legacy System
C->>OC: POST /api/v1/operations/{id}/execute (Bearer JWT)
OC->>OS: execute(id)
OS->>OS: Operation → PROCESSING (se persiste)
OS->>GW: authorize(externalId, amount, currency)
GW->>GW: arma AuthorizeOperationRequest (JAXB)
GW->>ML: HTTP POST /ws/legacy (SOAP/XML)
ML-->>GW: authorizeOperationResponse (SOAP/XML)
GW-->>OS: LegacyOperationResult (sin SOAP)
OS->>OS: Operation → COMPLETED o FAILED
OS-->>OC: OperationResponse
OC-->>C: 200 JSON
LegacyOperationGateway. Frontera explícita entre el dominio de LegacyBridge y el sistema legacy. Tiene un solo método (authorize(externalId, amount, currency)) a propósito: el servicio no necesita conocer SOAP, XML, JAXB ni el transporte.
SoapLegacyOperationGateway. Única implementación y única clase del proyecto que importa Spring-WS o JAXB. Arma el request, lo envía con WebServiceTemplate.marshalSendAndReceive(...) y traduce la respuesta, o cualquier fallo de comunicación, a LegacyOperationResult / LegacyIntegrationException.
Mock Legacy System (MockLegacyEndpoint). Simula el sistema legacy para poder ejecutar el proyecto sin depender de un tercero. Corre embebido en la misma app (en /ws/*), pero se le habla por HTTP real, exactamente como a un legacy en otra red: la única diferencia es la URL (legacy.soap.endpoint-url). Regla simulada, determinística: rechaza operaciones con amount > 100000 y autoriza el resto con un legacyOperationId único.
<!-- Request: LegacyBridge → Mock Legacy System -->
<authorizeOperationRequest xmlns="http://legacybridge.com/legacy/operation">
<externalId>OP-1001</externalId>
<amount>2500.00</amount>
<currency>PEN</currency>
</authorizeOperationRequest><!-- Response: Mock Legacy System → LegacyBridge -->
<authorizeOperationResponse xmlns="http://legacybridge.com/legacy/operation">
<legacyOperationId>LEGACY-0d9e61cd-5802-4eb9-a049-83bff8632cd0</legacyOperationId>
<status>OK</status>
<message>Operacion autorizada por el sistema legacy</message>
</authorizeOperationResponse>POST /api/v1/auth/login (usuario + password)
→ AuthenticationManager valida con UserDetailsServiceImpl + BCrypt
→ JwtService genera el JWT
→ respuesta: { token, tokenType, expiresInSeconds }
Request con Authorization: Bearer <token>
→ JwtAuthenticationFilter valida el token y puebla el SecurityContext
→ SecurityFilterChain exige autenticación
→ @PreAuthorize exige ROLE_ADMIN para crear y ejecutar operaciones
Consultar operaciones solo requiere estar autenticado. Crear y ejecutar exige ROLE_ADMIN: una operación financiera no la autoriza cualquiera.
| Método | Ruta | Requiere | Descripción |
|---|---|---|---|
POST |
/api/v1/auth/login |
— | Autentica y devuelve un JWT |
GET |
/api/v1/operations |
Usuario autenticado | Lista las operaciones |
GET |
/api/v1/operations/{id} |
Usuario autenticado | Obtiene una operación |
POST |
/api/v1/operations |
ROLE_ADMIN |
Crea una operación en estado PENDING |
POST |
/api/v1/operations/{id}/execute |
ROLE_ADMIN |
La envía al legacy por SOAP y actualiza su estado |
Login
POST /api/v1/auth/login
Content-Type: application/json
{ "username": "admin.demo", "password": "Admin#12345" }{ "token": "eyJhbGciOiJIUzM4NCJ9...", "tokenType": "Bearer", "expiresInSeconds": 3600 }Crear una operación
POST /api/v1/operations
Authorization: Bearer <token de ROLE_ADMIN>
Content-Type: application/json
{ "externalId": "OP-1001", "amount": 2500.00, "currency": "PEN" }{
"id": 1,
"externalId": "OP-1001",
"amount": 2500.00,
"currency": "PEN",
"status": "PENDING",
"createdAt": "2026-09-07T10:00:00Z",
"updatedAt": "2026-09-07T10:00:00Z"
}Ejecutar: el legacy la autoriza
{
"id": 1,
"externalId": "OP-1001",
"amount": 2500.00,
"currency": "PEN",
"status": "COMPLETED",
"legacyOperationId": "LEGACY-0d9e61cd-5802-4eb9-a049-83bff8632cd0",
"createdAt": "2026-09-07T10:00:00Z",
"updatedAt": "2026-09-07T10:00:05Z"
}Ejecutar: el legacy la rechaza (monto mayor a 100000)
{
"id": 2,
"externalId": "OP-9999",
"amount": 999999.00,
"currency": "PEN",
"status": "FAILED",
"legacyOperationId": null,
"createdAt": "2026-09-07T10:00:00Z",
"updatedAt": "2026-09-07T10:00:05Z"
}El legacy no responde (502)
{
"timestamp": "2026-09-07T10:00:00Z",
"status": 502,
"error": "Bad Gateway",
"message": "No se pudo establecer comunicacion con el sistema legacy",
"path": "/api/v1/operations/1/execute",
"details": []
}Todas las respuestas de error usan el mismo contrato ApiError (timestamp, status, error, message, path, details), sin importar si el error nace en Bean Validation, Spring Security o la integración legacy.
| Situación | HTTP | Origen |
|---|---|---|
| Body inválido (falta un campo, monto ≤ 0) | 400 | MethodArgumentNotValidException |
| Sin token, token inválido o expirado | 401 | AuthenticationEntryPoint de SecurityConfig |
Autenticado pero sin ROLE_ADMIN |
403 | AccessDeniedHandler / @PreAuthorize |
| Operación inexistente | 404 | ResourceNotFoundException |
externalId duplicado |
409 | DataIntegrityViolationException |
| El legacy no responde o la respuesta no se puede interpretar | 502 | LegacyIntegrationException |
| Error no anticipado | 500 | Catch-all |
- 502 y no 500 ante fallas del legacy. El error no es de LegacyBridge sino del sistema aguas arriba; 502 Bad Gateway comunica eso al cliente y a quien monitorea.
PROCESSINGse persiste antes de llamar al legacy. Si la llamada se cae a mitad de camino, la operación no queda enPENDINGcomo si nunca se hubiera intentado: queda registrado que se envió.execute()no es@Transactional. Cada cambio de estado se guarda por separado. Así no se mantiene una transacción de base de datos abierta mientras se espera una llamada de red al legacy, y cada estado intermedio queda visible.- 401 y 403 explícitos. Spring Security por defecto responde 403 a peticiones sin token; se configuraron
AuthenticationEntryPointyAccessDeniedHandlerpara distinguir "no autenticado" de "sin permiso" y responder con el mismoApiError. - Legacy simulado por HTTP real, no un mock en memoria: el cliente SOAP se ejercita igual que contra un sistema externo.
- JAXB con
setClassesToBeBounden lugar desetContextPath, para no depender de clases generadas (ObjectFactory/jaxb.index).
| Categoría | Tecnología |
|---|---|
| Lenguaje | Java 17 |
| Framework | Spring Boot 3.3.4 (Spring MVC, Spring Data JPA) |
| Seguridad | Spring Security, JWT (JJWT 0.12.6), BCrypt |
| Integración legacy | Spring Web Services 4, JAXB (Jakarta XML Bind), WSDL (wsdl4j) |
| Base de datos | Microsoft SQL Server (H2 en memoria para tests) |
| Validación | Jakarta Bean Validation |
| Documentación | springdoc-openapi (Swagger UI) + WSDL publicado |
| Testing | JUnit 5, Mockito, MockMvc, Spring Security Test, Spring-WS Test |
| Build | Maven |
| Infraestructura local | Docker / Colima (SQL Server) |
| Utilidades | Lombok |
Requisitos: Java 17+, Maven 3.9+ y Docker (o Colima en macOS).
# 1. SQL Server local
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Legacybridge#1" \
-p 1433:1433 --name legacybridge-sqlserver -d mcr.microsoft.com/azure-sql-edge
# 2. Crear la base de datos (ver sección 12 si no tienes un cliente SQL)
# 3. Arrancar la API
mvn spring-boot:run- API:
http://localhost:8080 - Swagger UI:
http://localhost:8080/swagger-ui.html - WSDL del legacy simulado:
http://localhost:8080/ws/legacyOperation.wsdl
Al arrancar, DemoUserSeeder crea dos usuarios solo para desarrollo:
| Usuario | Password | Rol |
|---|---|---|
user.demo |
User#12345 |
ROLE_USER |
admin.demo |
Admin#12345 |
ROLE_ADMIN |
# 1. Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin.demo","password":"Admin#12345"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
# 2. Crear una operación
OP_ID=$(curl -s -X POST http://localhost:8080/api/v1/operations \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"externalId":"OP-2001","amount":100.00,"currency":"PEN"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
# 3. Ejecutarla contra el sistema legacy (SOAP/XML por debajo)
curl -X POST "http://localhost:8080/api/v1/operations/$OP_ID/execute" \
-H "Authorization: Bearer $TOKEN"Para ver el rechazo del legacy, crea una operación con amount mayor a 100000.
La configuración vive en src/main/resources/application.yml y se resuelve por variables de entorno. Los valores por defecto son solo de desarrollo local; el repositorio no contiene credenciales reales.
| Variable | Default (desarrollo) | Descripción |
|---|---|---|
DB_HOST |
localhost |
Host de SQL Server |
DB_PORT |
1433 |
Puerto |
DB_NAME |
legacybridge |
Base de datos |
DB_USERNAME |
sa |
Usuario |
DB_PASSWORD |
Legacybridge#1 |
Password del contenedor local |
JWT_SECRET |
valor dev-only-... |
Secreto de firma del JWT. En cualquier entorno real debe venir de una variable de entorno o un vault |
JWT_EXPIRATION_MS |
3600000 |
Vigencia del token (1 hora) |
LEGACY_SOAP_URL |
http://localhost:8080/ws/legacy |
URL del sistema legacy; por defecto, el simulado embebido |
SERVER_PORT |
8080 |
Puerto de la aplicación |
La URL JDBC usa encrypt=true;trustServerCertificate=true para conectarse en local sin certificado TLS válido. En producción no se debe usar trustServerCertificate=true.
SQL Server no tiene binario nativo para macOS. Este proyecto se desarrolló con Colima como runtime de contenedores e imagen azure-sql-edge, que tiene build nativo arm64 y habla el mismo protocolo TDS (el driver JDBC no nota la diferencia). En Intel o Windows, mcr.microsoft.com/mssql/server:2022-latest funciona igual.
brew install colima docker
colima start --cpu 2 --memory 4 --disk 20
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Legacybridge#1" \
-p 1433:1433 --name legacybridge-sqlserver -d mcr.microsoft.com/azure-sql-edgecat > /tmp/CreateDb.java <<'EOF'
import java.sql.*;
public class CreateDb {
public static void main(String[] a) throws Exception {
try (Connection c = DriverManager.getConnection(
"jdbc:sqlserver://localhost:1433;encrypt=true;trustServerCertificate=true", "sa", "Legacybridge#1");
Statement s = c.createStatement()) {
s.execute("IF DB_ID('legacybridge') IS NULL CREATE DATABASE legacybridge");
}
}
}
EOF
JAR=$(find ~/.m2 -iname "mssql-jdbc*.jar" | head -1)
javac -d /tmp /tmp/CreateDb.java && java -cp "/tmp:$JAR" CreateDbHibernate crea y actualiza el esquema automáticamente (ddl-auto: update).
ddl-auto: updateno actualiza CHECK constraints. Hibernate 6 genera un CHECK en la columnastatuscon los valores del enum al crear la tabla. Al cambiar los estados aPENDING/PROCESSING/COMPLETED/FAILED,updateagregó columnas nuevas pero dejó el CHECK viejo, y cualquierUPDATEa un estado nuevo fallaba con SQL Error 547. En desarrollo se resuelve recreando la tabla; con datos reales, es exactamente el caso que exige una migración explícita (Flyway/Liquibase).- Collation case-insensitive. La collation por defecto de SQL Server (
SQL_Latin1_General_CP1_CI_AS) trataOP-1001yop-1001como el mismoexternalId. Si el legacy real distinguiera mayúsculas, la columna necesitaría una collation_CS_AS. Instant→datetime2. SQL Server no guarda el huso horario comotimestamptzde PostgreSQL; el UTC se mantiene por convención de la aplicación, no por el motor.
mvn test30 tests:
- Autenticación y seguridad:
AuthenticationIntegrationTest(login correcto e incorrecto, 401 sin token o con token inválido, 403 paraROLE_USERy 201 paraROLE_ADMINal crear operaciones),JwtServiceTest(generación y validación del token, rechazo de tokens manipulados o emitidos para otro usuario) yAuthenticationServiceTest. - Operaciones: reglas de
OperationServicey validación de DTOs en el controller (OperationControllerValidationTest). SoapLegacyOperationGatewayTest(Mockito sobreWebServiceTemplate): respuesta exitosa, contenido del request SOAP, rechazo del legacy y fallo de comunicación →LegacyIntegrationException.OperationServiceExecuteTest(Mockito sobre el gateway): éxito →COMPLETEDconlegacyOperationId; rechazo →FAILED; fallo de comunicación →FAILEDy excepción propagada; id inexistente → nunca llama al gateway.MockLegacyEndpointTest(MockWebServiceClientde Spring-WS): envía XML real al@Endpointy valida éxito y error.LegacyOperationExecutionIntegrationTest(@SpringBootTestcon servidor embebido real): login → crear operación →POST /execute→ SOAP real sobre HTTP contra el legacy simulado → operación actualizada en H2. Cubre el camino feliz y el rechazo.
El test de integración levanta el servidor en el puerto 8080. Si otro proceso lo está usando, falla. Puedes correr la suite en otro puerto:
mvn test -Dserver.port=18080 -Dlegacy.soap.endpoint-url=http://localhost:18080/ws/legacy
Larry Bryan Quiroz Flores — Ingeniero de Software · GeneXus · Bantotal · Java · Integraciones
- GitHub: LarryBryan7
- Experiencia relacionada: App Aprobadores — caso de estudio (servicios SOAP en GeneXus sobre Bantotal, consumidos por una app Android/Java)





