We’re kicking off the next installment of our series on microservice architecture patterns. As in previous articles, here are the posts we've already published, just in case you missed any and want to dive deeper into the microservice architecture patterns we've already explored:

  1. Microservices Architecture Patterns: What Are They and What Benefits Do They Offer?
  2. Architecture Patterns: Organization and Structure of Microservices.
  3. Architecture Patterns: Microservices Communication and Coordination.
  4. Microservices Architecture Patterns: SAGA, API Gateway, and Service Discovery.
  5. Microservices Architecture Patterns: Event Sourcing and Event-Driven Architecture (EDA).
  6. Microservices Architecture Patterns: Communication and Coordination with CQRS, BFF, and Outbox.
  7. Microservices Patterns: Scalability and Resource Management with Auto Scaling.
  8. Architecture Patterns: From Monolith to Microservices.
  9. Externalized Configuration.

In today’s architectures, the increasing demand for frequent deployments has pushed teams and organizations toward distributed models, where each service plays a specific role within the platform. However, this freedom also introduces new challenges when evolving APIs without breaking dependent services.

To address this issue, we have the microservices architecture pattern known as Consumer-Driven Contract Testing (CDCT), which we’ll explore in the following ways:

Visual diagram of the Consumer-Driven Contract Testing (CDCT) process. It shows how a consumer (client) defines expectations in a contract (Pact), which is then used to generate tests run against the provider (API/Service).

Here we see a visual diagram of the Consumer-Driven Contract Testing (CDCT) process. It illustrates how a consumer defines expectations in a contract (Pact), which is then used to generate tests that are executed against the provider (API/Service). The test results validate whether the contract is still being honored.

Note: Code examples are provided solely for conceptual understanding.

Challenges in a microservices environment with evolving APIs

A service may change the format of its response or add new fields to support business functionality.

If another microservice consumes that API and isn’t aware of the modification, this can lead to production errors.

Maintaining integration tests that cover the entire ecosystem is often costly and not always agile.

Consumer-Driven Contract Testing (CDCT)

Context and motivation

In a microservices architecture, it’s common for service A (the consumer) to require data or actions from service B (the provider).

For example, orders-service calls payment-service to process a payment, and it returns a JSON with the transaction status. If payment-service changes that JSON format (e.g., replacing confirmationUrl with confirmLink), orders-service could break because it’s expecting a specific field.

Traditional integration tests (end-to-end) address this by spinning up all services at once and verifying the interaction. But this approach is expensive in terms of time, infrastructure, and coordination, especially when dealing with dozens or hundreds of microservices.

Consumer-Driven Contract Testing offers a more lightweight and scalable alternative:

CDCT Principles

  1. Consumer-driven contracts:
  1. Contract versioning and publishing:
  1. Continuous integration:
  1. Team collaboration:

Tools and approaches

Pact

Spring Cloud Contract

Dredd

Microcks, Hoverfly, Mountebank

Practical example

Let’s look at a real-world case where orders-service consumes the /pay endpoint of payment-service.

The business scenario is: the user confirms an order, orders-service calls payment-service with a JSON body that includes the total amount, order ID, payment method, etc. payment-service responds with a payment ID (paymentId), a status (APPROVED, DECLINED), and a confirmation URL.

Defining the contract in the consumer

In orders-service, we write a contract test using Pact. Suppose we’re using JUnit + Pact-JVM. This can be done in Java or Groovy.
Here’s a simplified Java example:

@ExtendWith(SpringExtension.class)
@PactTestFor(providerName = "payment-service", port = "8080")
class PaymentContractTest {

    @Pact(consumer = "orders-service", provider = "payment-service")
    public RequestResponsePact createPaymentPact(PactDslWithProvider builder) {
        return builder
            .uponReceiving("A request to create a payment")
                .path("/pay")
                .method("POST")
                .body("{\"orderId\":1234,\"amount\":99.90,\"method\":\"CREDIT_CARD\"}")
            .willRespondWith()
                .status(200)
                .body("{\"paymentId\":\"abc-123\",\"status\":\"APPROVED\",\"confirmationUrl\":\"https://payments.example.com/confirm/abc-123\"}")
            .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "createPaymentPact")
    void verifyCreatePaymentPact(MockServer mockServer) {
        RestTemplate restTemplate = new RestTemplate();
        Map<String, Object> request = new HashMap<>();
        request.put("orderId", 1234);
        request.put("amount", 99.90);
        request.put("method", "CREDIT_CARD");

        ResponseEntity<Map> response = restTemplate.postForEntity(
            "http://localhost:" + mockServer.getPort() + "/pay",
            request,
            Map.class
        );

        assertEquals(200, response.getStatusCodeValue());
        assertEquals("APPROVED", response.getBody().get("status"));
        assertTrue(response.getBody().containsKey("confirmationUrl"));
    }
}

When running this test in orders-service, Pact generates a .json file (the pact file) that describes the interaction:

{
  "provider": {
    "name": "payment-service"
  },
  "consumer": {
    "name": "orders-service"
  },
  "interactions": [
    {
      "description": "A request to create a payment",
      "request": {
        "method": "POST",
        "path": "/pay",
        "body": {
          "orderId": 1234,
          "amount": 99.90,
          "method": "CREDIT_CARD"
        }
      },
      "response": {
        "status": 200,
        "body": {
          "paymentId": "abc-123",
          "status": "APPROVED",
          "confirmationUrl": "https://payments.example.com/confirm/abc-123"
        }
      }
    }
  ]
}

This file can be published to a Pact Broker using a Gradle/Maven script that uploads it during the post-test phase. We can associate it with the specific version of the consumer (e.g., orders-service v1.3.0).

Contract verification on the provider side

On the other hand, payment-service (the provider) integrates a step in its pipeline to download the latest pact file from the broker and execute it against its real or simulated implementation. Let’s say we have a test called PaymentProviderTest that launches the actual application on a random port and has Pact replicate the interaction.

In Groovy:

class PaymentProviderTest extends Specification {

    @Shared
    @AutoStart // Assume an extension that initiates the paid service
    ApplicationUnderTest paymentApp = new PaymentAppUnderTest()

    @PactVerification(provider = "payment-service", consumer = "orders-service")
    def "should validate the pact with the ordering service"() {
        // No need to write logic, Pact takes care of triggering the interaction

        expect:
        // Pact will validate the request and the actual response
        true
    }
}

The verification will check that when calling POST /pay with that body, payment-service returns paymentId, status, and confirmationUrl exactly as defined.

If any attribute differs (for example, the service changed confirmationUrl → link), the verification will fail, and the build will not continue.

This way, the payment-service team is immediately notified that their planned change would break orders-service expectations, allowing them to negotiate or version the API before causing a production failure.

Integration into CI/CD pipelines

  1. orders-service build:
  1. payment-service build:

This methodology extends to all services depending on payment-service, ensuring none of them break due to unexpected changes.

What are the benefits?

Recommended best practices

Advanced scenarios

Conclusions and next steps

With Consumer-Driven Contract Testing, we anticipate API breakages, promote collaboration between development teams, and avoid costly production incidents.

It empowers consumers to define their contract, which is then continuously validated in the provider’s pipeline, preventing last-minute surprises in production and strengthening team collaboration. Plus, tools like Pact, Spring Cloud Contract, and Dredd make adoption easier.

If you're concerned about API breakages and have already experienced production incidents, start by documenting the main interactions between your services. Define one or two contracts using the CDCT approach and configure the provider’s pipeline. You’ll see how issues get caught before they reach your users.

A robust microservice also needs well-designed logs, distributed tracing (observability), and resilience mechanisms (circuit breakers, timeouts). CDCT isn’t a standalone solution, it complements your broader DevOps/Cloud Native strategy.

In future posts, we’ll dive deeper into topics like security patterns, fault tolerance, monitoring, and observability, essential for operating a large-scale microservice platform that meets high performance and compliance demands.

Tell us what you think.

Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.

Subscribe