The consumer expects id, name, and price. The provider renames price to amount, passes its own tests, and goes to production. The consumer also stays green in CI: its test uses a mock and does not know that the provider changed later. Both services are “correct” when viewed in isolation, which is a rather elaborate way to produce an incompatibility.
Pact closes this gap in two steps. First, the consumer test records the interactions it actually uses. Then the provider replays those interactions against its implementation. With the results published to Pact Broker, can-i-deploy checks the compatibility matrix and blocks a version without a valid verification for the target environment.
The expected outcome is verifiable in the pipeline:
- the consumer test generates a Pact file in
target/pacts; - provider verification replays the contract and fails on an incompatible response;
- the Broker records versions and results;
pact-broker can-i-deployreturns code1when the matrix does not authorize the deployment.
Versions Used and the Upgrade Path
The examples use:
- Java 21 LTS;
- Spring Boot 3.5.16;
- Pact-JVM 4.7.5;
au.com.dius.pact.consumer:junit5:4.7.5in the consumer;au.com.dius.pact.provider:spring6:4.7.5in the provider.
Spring Boot 4.1.0 is the current stable line and supports Java 17 through 26, according to the official requirements. However, the Pact spring6 module is documented for Spring 6 and Spring Boot 3. Do not treat Boot 4.1 with spring6 as a jointly verified matrix. For Boot 4.1/Spring 7, use the au.com.dius.pact.provider:junit5 module with HttpTestTarget until Pact documents Spring 7 support.
Spring Boot 3.5.16 supports Java up to 25. Pact-JVM 4.7.x documentation, meanwhile, lists testing up to Java 23. Java 25 is therefore a possible upgrade on the Boot side, but run the Pact suite in your project before adopting it as the default.
Keep the test dependencies separate by application. In the consumer:
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.7.5</version>
<scope>test</scope>
</dependency>
In the provider:
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>spring6</artifactId>
<version>4.7.5</version>
<scope>test</scope>
</dependency>
For verification with JUnit 5, keep Maven Surefire at 2.22.1 or later and disable the system classloader:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<useSystemClassLoader>false</useSystemClassLoader>
<systemPropertyVariables>
<pact.provider.version>${env.GIT_SHA}</pact.provider.version>
<pact.provider.branch>${env.GIT_BRANCH}</pact.provider.branch>
<pact.verifier.publishResults>true</pact.verifier.publishResults>
</systemPropertyVariables>
</configuration>
</plugin>
These properties must reach the test JVM. Setting them only on the process that started Maven and assuming everything will be forwarded is one of those implicit contracts that Pact cannot save.
The Consumer Defines What It Actually Uses
Pact is consumer-driven. The contract is created while the consumer calls a mock server controlled by the Pact extension; it is not a separately authored OpenAPI schema.
Consider a store-consumer that fetches products from catalog-provider through a small client:
package academy.devdojo.store;
import org.springframework.web.client.RestClient;
public final class CatalogClient {
private final RestClient restClient;
public CatalogClient(String baseUrl) {
this.restClient = RestClient.create(baseUrl);
}
public Product findById(long id) {
return restClient.get()
.uri("/products/{id}", id)
.retrieve()
.body(Product.class);
}
public record Product(long id, String name, double price) {}
}
The test below describes an existing product and calls the mock supplied by Pact:
package academy.devdojo.store;
import static org.junit.jupiter.api.Assertions.assertEquals;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(PactConsumerTestExt.class)
class CatalogClientPactTest {
@Pact(provider = "catalog-provider", consumer = "store-consumer")
RequestResponsePact getProduct(PactDslWithProvider builder) {
return builder
.given("product 42 exists")
.uponReceiving("a request for product 42")
.path("/products/42")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body("{\"id\":42,\"name\":\"Cafe\",\"price\":19.90}")
.toPact();
}
@Test
@PactTestFor(pactMethod = "getProduct")
void returnsProduct(MockServer mockServer) {
var client = new CatalogClient(mockServer.getUrl());
var product = client.findById(42);
assertEquals(42, product.id());
assertEquals("Cafe", product.name());
assertEquals(19.90, product.price());
}
}
When you run mvn test, the test calls only the mock and writes target/pacts/store-consumer-catalog-provider.json. This proves that the consumer produces the described request and understands the agreed response. It does not prove that the provider's current or future version still returns that response.
A 404 case follows the same format: declare another @Pact method with given("product 99 does not exist"), path /products/99, and status 404; then test the client's error handling. The named state will be prepared on the provider side with @State.
The Provider Must Prove It Honors the Contract
A minimal endpoint implementation can look like this:
package academy.devdojo.catalog;
import java.math.BigDecimal;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/products")
class ProductController {
@GetMapping("/{id}")
Product findById(@PathVariable long id) {
if (id != 42) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
return new Product(42, "Cafe", new BigDecimal("19.90"));
}
record Product(long id, String name, BigDecimal price) {}
}
Verification starts Spring Boot on a defined port, fetches contracts from the Broker, and runs each interaction against the real provider:
package academy.devdojo.catalog;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import au.com.dius.pact.provider.spring.junit5.PactVerificationSpring6Provider;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT,
properties = "server.port=8080")
@Provider("catalog-provider")
@PactBroker
class ContractVerificationTest {
@State("product 42 exists")
void productExists() {
// Prepare aqui os dados exigidos pela interação.
}
@State("product 99 does not exist")
void productDoesNotExist() {
// Garanta aqui que o produto não está no repositório de teste.
}
@TestTemplate
@ExtendWith(PactVerificationSpring6Provider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
}
In an application with a database, the @State methods must insert or remove the required data deterministically. If the state is not prepared, the failure appears in the provider verification logs with the interaction and the status or body difference. That is the safe observation point for CI: do not depend on reproducing the problem after deployment.
To start without a Broker, copy the generated JSON to catalog-provider/src/test/resources/pacts and replace @PactBroker with:
import au.com.dius.pact.provider.junitsupport.loader.PactFolder;
@PactFolder("pacts")
This local path confirms that the consumer generates a contract and the provider can verify it. It does not provide a version matrix, webhooks, or deployment decisions.
If security filters, HTTP serialization, or server configuration are part of the relevant behavior, prefer DEFINED_PORT. Verification with @WebMvcTest and Spring6MockMvcTestTarget is lighter, but it does not exercise the entire stack. In addition, with @WebMvcTest, the appropriate extension is PactVerificationInvocationContextProvider, not PactVerificationSpring6Provider.
Publishing, Branches, and Environments in Pact Broker
The Pact JSON should not become a Maven artifact. Publish it with the consumer application version, usually the commit SHA:
pact-broker publish target/pacts \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH" \
--broker-base-url "$PACT_BROKER_BASE_URL" \
--broker-username "$PACT_BROKER_USERNAME" \
--broker-password "$PACT_BROKER_PASSWORD"
Configure the Broker in the provider test without storing credentials in the repository:
pactbroker:
host: ${PACT_BROKER_HOST}
auth:
username: ${PACT_BROKER_USERNAME}
password: ${PACT_BROKER_PASSWORD}
The provider CI selects the relevant contracts, runs verification, and publishes the result with pact.provider.version, pact.provider.branch, and pact.verifier.publishResults=true. For repositories with concurrent branches, use Consumer Version Selectors such as matchingBranch() and deployedOrReleased() instead of indiscriminately verifying every version ever published.
After a successful deployment, record the version in the environment:
pact-broker record-deployment \
--pacticipant store-consumer \
--version "$GIT_SHA" \
--environment production \
--broker-base-url "$PACT_BROKER_BASE_URL" \
--broker-username "$PACT_BROKER_USERNAME" \
--broker-password "$PACT_BROKER_PASSWORD"
Environments and record-deployment are preferable to tags for representing what is in production. The Broker matrix then shows which consumer and provider versions have a successful verification and which versions are deployed in each environment.
The Right Gate for a Breaking Change
The consumer test does not start failing when someone changes the provider hours or days later. Blocking happens at two coordinated points:
- provider CI fetches the Pact and fails when it verifies an incompatible response;
- before deployment, both consumer and provider query
can-i-deployfor their own version.
For the consumer:
pact-broker can-i-deploy \
--pacticipant store-consumer \
--version "$GIT_SHA" \
--to-environment production \
--broker-base-url "$PACT_BROKER_BASE_URL" \
--broker-username "$PACT_BROKER_USERNAME" \
--broker-password "$PACT_BROKER_PASSWORD"
The job must preserve the command output and its return code. Code 0 releases the next stage; code 1 stops the deployment. Also consult the Broker matrix and the official can-i-deploy explanation when the gate rejects a version.
Two scenarios make the responsibility clear:
- if the provider removes
price, its verification fails; the CI log shows the difference, andcan-i-deploydoes not authorize that version against the deployed consumers; - if the consumer publishes a new interaction, its version cannot be deployed until a compatible provider verifies it.
Pending pacts prevent a new, not-yet-verified contract from immediately breaking provider CI. They do not authorize deployment: the missing verification remains visible in the matrix and blocks can-i-deploy.
Pitfalls and Limits That Matter
Use one playbook to diagnose the integration:
| Signal | Concrete fix | Safe observation in CI or Broker |
|---|---|---|
The provider returns 404 for the happy path | Implement @State("product 42 exists") to prepare the data | Interaction log in provider verification |
| A field change goes unnoticed | Verify the Pact in provider CI and use explicit matchers for relevant fields | Body difference in the log and verification result in the matrix |
| The matrix accumulates ambiguous versions | Publish consumer and provider with the Git SHA, not 1.0-SNAPSHOT | Identifiable versions and branches in the Broker |
| Verification passes, but the result does not appear | Forward pact.verifier.publishResults=true and the version through the Surefire JVM | Result absent or present in the Broker matrix |
| The pipeline publishes and deploys without checking compatibility | Run can-i-deploy immediately before deployment | Exit code 1 blocks the job |
Avoid matchers so broad that any object passes and exact responses so strict that harmless differences, such as variable values, become significant. The contract should express what the consumer needs: names, types, formats, and relevant states.
Contract tests do not replace load tests or end-to-end flows. They do not prove capacity, a real database, network policies, production authentication, clocks, or a business process spanning multiple services. Nor do they eliminate consumer unit tests for mapping and error handling. Pact's role is narrower: verify that request and response examples used by known versions remain compatible.
DevDojo's recommendation is firm: adopt Pact when consumer and provider have independent deployment cycles and a payload break must prevent promotion between environments. Start with one important endpoint, verification in provider CI, and the Broker as a gate. If the services are always released together and a short integration test already covers the boundary clearly, the cost of maintaining states, versions, and the Broker may not pay off.
Next Step
Choose a call that would already cause an impact if a field disappeared. Generate the first Pact, have the provider verify it, and add can-i-deploy to the existing job. There is no need to rewrite the testing pyramid; just stop letting the contract between services exist only in the team's memory.