
El problema de los microservicios desacoplados
Tu plataforma tiene 14 microservicios, cada uno con su pipeline. El equipo del servicio de Pagos cambia el campo currencyCode por currency en una respuesta, despliega el viernes y se va de fin de semana. El servicio de Facturación, que consume Pagos, comienza a fallar el sábado por la mañana cuando un cliente intenta cobrar. Nadie verificó la integración porque "los tests pasaban en cada lado".
Esto pasa porque los tests del provider verifican que el endpoint funciona y los tests del consumer verifican el código del consumer (con stubs propios), pero nadie verifica que las expectativas del consumer coinciden con el comportamiento del provider. Levantar un entorno integrado en CI con todos los servicios en su versión más reciente es lento, frágil y caro.
Pact resuelve esto con consumer-driven contracts: el consumer escribe un test que define qué petición hace y qué respuesta espera; eso genera un fichero JSON (el pact) que el provider verifica contra su implementación real. Si el provider rompe el formato, su pipeline falla antes de desplegar.
Consumer-driven significa que el consumer dicta el contrato. El provider no decide qué expone; descubre qué consumers existen y qué necesita cada uno. Eso fuerza el alineamiento real entre equipos.
Cómo funciona el flujo
graph LR
A[Consumer test] --> B[Mock server interno]
B --> C[Pact JSON generado]
C --> D[Pact Broker]
D --> E[Provider verifica]
E --> F[can-i-deploy]
F --> G[Despliegue seguro]
- El consumer escribe un test contra un mock server local de Pact: "cuando llamo a
GET /pagos/{id}, espero200con el campoid,amountycurrency". - Pact JUnit 6 genera el fichero JSON
consumer-provider.jsonentarget/pacts/. - El consumer publica ese fichero al Pact Broker (un servidor central).
- El provider, en su pipeline, descarga los pacts publicados por sus consumers y los replica contra su implementación real.
- Si la respuesta real no coincide con el contrato, el provider build falla.
- Antes de desplegar,
can-i-deploy --to=productionverifica que la versión actual es compatible con todas las versiones desplegadas de los consumers.
Setup del consumer
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.14</version>
<scope>test</scope>
</dependency>
pact:
consumer:
name: facturacion-service
provider:
name: pagos-service
Escribir un test de consumer
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "pagos-service")
class PagoClientPactTest {
@Pact(consumer = "facturacion-service")
public RequestResponsePact consultaPagoPorId(PactDslWithProvider builder) {
return builder
.given("existe un pago con id pago_123")
.uponReceiving("una consulta de pago por id")
.path("/v1/payments/pago_123")
.method("GET")
.matchHeader("Accept", "application/json")
.willRespondWith()
.status(200)
.matchHeader("Content-Type", "application/json")
.body(new PactDslJsonBody()
.stringType("id", "pago_123")
.numberType("amount", 1500)
.stringMatcher("currency", "[A-Z]{3}", "EUR")
.stringMatcher("status", "OK|FAILED|PENDING", "OK")
.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ssXXX")
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "consultaPagoPorId")
void consulta_pago_correctamente(MockServer mockServer) {
var client = new PagoClient(RestClient.builder()
.baseUrl(mockServer.getUrl())
.build());
var pago = client.consultarPorId("pago_123");
assertThat(pago.id()).isEqualTo("pago_123");
assertThat(pago.amount()).isEqualTo(1500);
assertThat(pago.currency()).isEqualTo("EUR");
}
}
Tras ejecutar el test, Pact genera target/pacts/facturacion-service-pagos-service.json:
{
"consumer": { "name": "facturacion-service" },
"provider": { "name": "pagos-service" },
"interactions": [{
"providerStates": [{ "name": "existe un pago con id pago_123" }],
"description": "una consulta de pago por id",
"request": {
"method": "GET",
"path": "/v1/payments/pago_123",
"headers": { "Accept": "application/json" }
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": {
"id": "pago_123",
"amount": 1500,
"currency": "EUR",
"status": "OK",
"createdAt": "2026-05-07T10:00:00+00:00"
},
"matchingRules": { ... }
}
}]
}
Las
matchingRulesson la clave: Pact no exige que el provider devuelva exactamenteEUR, sino cualquier string que matchee[A-Z]{3}. Esto evita falsos positivos cuando el provider devuelve datos reales con valores distintos a los del ejemplo.
Estados (provider states)
Cada interacción declara un estado precondicional con given(...). El provider, al verificar, deberá poder llevar su sistema a ese estado antes de replicar la petición. Es el mecanismo que permite testear casos edge como "no existe el recurso", "el usuario está bloqueado" o "la cuenta tiene saldo insuficiente".
Setup del provider
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5spring</artifactId>
<version>4.6.14</version>
<scope>test</scope>
</dependency>
Verificación en el provider
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Provider("pagos-service")
@PactBroker(host = "broker.miempresa.com", scheme = "https")
@VerificationReports({ "console", "markdown" })
class PagoProviderVerificationTest {
@LocalServerPort
private int port;
@Autowired
private PagoRepository repository;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
@State("existe un pago con id pago_123")
void crearPago() {
repository.save(new Pago("pago_123", BigDecimal.valueOf(1500), "EUR", Status.OK));
}
@State(value = "existe un pago con id pago_123", action = StateChangeAction.TEARDOWN)
void borrarPago() {
repository.deleteById("pago_123");
}
}
El provider arranca su aplicación en un puerto aleatorio, descarga todos los pacts publicados contra pagos-service desde el Pact Broker y los replica uno a uno. Cada @State prepara el sistema para una interacción específica. Si la respuesta real no cumple el contrato, el test falla con un diff detallado del campo problemático.
Pact Broker
El Pact Broker es el servidor central donde se publican y consumen los pacts. Almacena versiones, etiquetas (tags) y resultados de verificación. Se autoaloja con Docker o se contrata como PactFlow.
Publicar desde el consumer:
mvn pact:publish \
-Dpact.broker.url=https://broker.miempresa.com \
-Dpact.consumer.version=$GIT_SHA \
-Dpact.consumer.tags=main
Verificar en el provider, leyendo del broker:
@PactBroker(
host = "broker.miempresa.com",
scheme = "https",
consumerVersionSelectors = {
@ConsumerVersionSelector(tag = "main", latest = true),
@ConsumerVersionSelector(tag = "production", latest = true)
}
)
El selector
tag = "production"es esencial: garantiza que el provider verifica contra la versión que el consumer tiene desplegada hoy en producción, no solo contra la última de la ramamain.
can-i-deploy: el control de despliegue
Antes de desplegar a producción, can-i-deploy consulta el broker:
"El provider
pagos-serviceversión1.5.0que pretendo desplegar... ¿es compatible con todas las versiones de consumers desplegadas en producción?"
pact-broker can-i-deploy \
--broker-base-url=https://broker.miempresa.com \
--pacticipant=pagos-service \
--version=$GIT_SHA \
--to-environment=production
- Salida
0→ safe to deploy. - Salida
1→ algún consumer espera comportamiento que la nueva versión rompe.
Esto se incluye en el job de CI antes del paso de deploy. Bloquea releases que romperían la integración antes de tocar producción.
Diferencias con Spring Cloud Contract
Spring Cloud Contract sigue el modelo opuesto: provider-driven. El provider escribe los contratos en Groovy/YAML y el consumer los descarga para generar stubs locales. Encaja bien cuando:
- Provider y consumer son del mismo equipo o de equipos muy alineados.
- La API tiene muchos consumers y el provider quiere publicar un único contrato canónico.
- El equipo ya tiene cultura Spring y no quiere añadir un Pact Broker.
Pact, en cambio, brilla cuando:
- Los consumers son equipos distintos, posiblemente con stacks distintos (Pact tiene clientes para JVM, Node, .NET, Go, Python, Ruby).
- Hay riesgo real de que el provider haga cambios sin coordinar con todos los consumers.
- Se quiere una métrica explícita de quién consume qué (Pact Broker la muestra en un grafo).
- El control
can-i-deployse quiere automatizar antes del despliegue.
| Dimensión | Pact | Spring Cloud Contract |
|-----------|------|----------------------|
| Dirección del contrato | Consumer dicta | Provider dicta |
| Idiomas soportados | Multi-stack | Principalmente JVM |
| Servidor central | Pact Broker (obligatorio en práctica) | Repositorio Maven/Git |
| Verificación gate | can-i-deploy listo de fábrica | Manual (descargar stubs y ejecutar) |
| Curva de adopción | Media-alta | Baja para equipos Spring |
Ventajas y limitaciones de Pact
Ventajas:
- Detecta breaking changes antes de producción sin levantar el entorno completo.
- Hace explícito quién consume qué API y cómo.
- Modelo multi-stack: el equipo de frontend (Node/TypeScript) y el de backend (Java) hablan el mismo lenguaje de contratos.
can-i-deployevita despliegues que romperían a otros equipos.
Limitaciones:
- No verifica comportamiento, solo formato. Que el provider devuelva
200 OKno significa que la lógica de negocio sea correcta. - No sirve para integraciones asíncronas a menos que se use Pact Message (variante para Kafka, RabbitMQ).
- Requiere disciplina: si el equipo no escribe consumer tests, el contrato no existe.
- El Pact Broker es un sistema más a operar (autoalojado o como SaaS).
- Casos con respuestas con campos dinámicos o paginación compleja requieren matchers cuidadosos para evitar falsos positivos.
Buenas prácticas
- Un pact por par consumer-provider, no un mega-contrato. Permite que cada equipo evolucione su parte sin coordinación bloqueante.
- Estados explícitos y reproducibles: evitar
given("la base de datos tiene varios pagos"). Mejorgiven("existe un pago con id X y amount Y"). - Tags semánticas:
main,production,staging. El provider verifica las que están vivas; las viejas se borran tras un retention period. - Etiquetar la versión con el SHA del commit: facilita el debugging cuando un test falla.
can-i-deployantes de cada deploy a producción, no solo en main. Convertirlo en un quality gate del pipeline.- Nunca usar Pact como sustituto de tests E2E críticos: Pact verifica contratos, no comportamiento end-to-end. Un E2E selectivo sigue siendo necesario para flujos críticos.
- Combinar con WireMock en la fase de desarrollo del consumer: WireMock para iterar rápido en tests del cliente, Pact para garantizar el contrato cuando el código está estable.
- Revisar el grafo del Pact Broker periódicamente: descubre integraciones que el equipo no recordaba mantener.
Pact convierte el desacoplamiento técnico en alineamiento contractual. Los equipos siguen desplegando independientemente, pero con una red de seguridad: un pipeline que dice "tu cambio rompe a tres consumers, mira aquí". En arquitecturas con más de cinco microservicios, esa red de seguridad se paga sola la primera vez que evita un incidente de producción que habría costado horas de guardia y disculpas a un cliente.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Boot es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Spring Boot
Explora más contenido relacionado con Spring Boot y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Diseñar un contrato consumer-driven y entender quién dicta el formato. Escribir un test de consumer con MockServerConfig + PactDslJsonBody y generar el fichero pact JSON. Verificar el contrato en el provider con @Provider, @PactBroker y states. Subir pacts al Pact Broker desde CI y consultar can-i-deploy antes de un release. Decidir cuándo usar Pact y cuándo Spring Cloud Contract según la dirección del contrato.