Published on

[The Ultimate Guide] Mastering Consumer-Driven Contracts: Preventing Breaking Changes in Event-Driven Spring Boot Microservices with Pact and Kafka

Authors
  • avatar
    Name
    Maria
    Twitter

Consumer-Driven Contracts (CDC) have emerged as a cornerstone strategy for building resilient, independently deployable microservices. In the complex landscape of event-driven architectures, particularly those powered by Spring Boot and Apache Kafka, ensuring that changes made by one service (the "producer") don't inadvertently break another (the "consumer") is a monumental challenge. Traditional end-to-end testing often falls short, becoming brittle, slow, and expensive to maintain. This guide delves deep into mastering Consumer-Driven Contracts using Pact, a powerful framework that allows your Spring Boot microservices to define and verify their interaction contracts, bringing unparalleled confidence to your Kafka-based event streams.

TL;DR: Consumer-Driven Contracts with Pact & Kafka

💡 CDC revolutionizes microservice integration testing by letting consumers define expected interactions. 🛠️ Pact enables robust contract verification for event-driven Spring Boot applications using Kafka. 🚀 Achieve independent deployments, prevent breaking changes, and accelerate development cycles.

The Microservice Integration Testing Conundrum in Event-Driven Architectures

Modern backend systems increasingly adopt microservice architectures, often leveraging event streaming platforms like Apache Kafka for asynchronous communication. This pattern, while offering incredible scalability and decoupling, introduces a significant testing challenge: how do you ensure that services integrate correctly when they communicate asynchronously via events?

Traditional testing approaches hit their limits rapidly:

  • Monolithic End-to-End (E2E) Tests: These tests attempt to validate the entire system, spanning multiple services and their interactions. While they offer a high level of confidence if they pass, they are notoriously slow, fragile, and difficult to diagnose when they fail. A single service regression can break dozens of E2E tests, making it hard to pinpoint the root cause. Moreover, managing test data across a complex distributed system is a nightmare. For event-driven systems, orchestrating event flows and verifying outcomes across multiple asynchronous components becomes nearly impossible to do reliably.
  • Producer-Side Contract Generation: A producer service might generate a schema (e.g., using Avro) and publish it. While useful for schema evolution, this approach doesn't guarantee that the actual data published by the producer adheres to what a consumer expects or can handle. The consumer still has to trust the producer to follow its own contract. This creates a reliance that can lead to runtime errors if the producer's interpretation differs from the consumer's, even if the schema technically remains compatible.

The core problem is one of trust and independence. In a truly decoupled microservice architecture, services should be able to evolve and deploy independently, without fear of breaking upstream or downstream dependencies. However, asynchronous communication inherently creates a hidden coupling: the contract of the event itself. Without a robust mechanism to manage and verify this contract, "independent deployment" remains an elusive dream.

Understanding Consumer-Driven Contracts (CDC)

Consumer-Driven Contracts (CDC) flip the traditional testing paradigm. Instead of the producer dictating the contract, the consumer defines the exact interaction it expects from the producer. This definition becomes a "contract" that the producer must then verify it fulfills.

What is CDC?

At its heart, CDC is a technique where each service consuming an event (or calling an API) explicitly states what it expects to receive from the producer. This expectation is captured in a test on the consumer's side. The generated contract from this consumer test is then provided to the producer. The producer runs a verification step to ensure that it indeed fulfills all contracts defined by its consumers.

This approach provides several profound benefits:

  1. Shifts Testing Left: Integration issues are caught much earlier in the development cycle, ideally before code is even committed or deployed.
  2. Increased Confidence in Deployments: Developers can deploy changes to a service with high confidence, knowing that their changes have not broken any existing consumer contracts. This significantly reduces the risk of production outages due to unexpected integration failures.
  3. Faster Feedback Loops: Developers receive immediate feedback on contract adherence, allowing them to iterate more quickly and fix issues before they become deeply embedded.
  4. Enables Independent Deployment: Each service can evolve and deploy independently, as long as it continues to satisfy its current contracts. This unlocks the true promise of microservices.
  5. Focuses on Real-World Usage: Contracts are driven by actual consumer needs, preventing producers from over-engineering or under-specifying their interfaces. It's about what's actually used, not just what's possible.

The Core Loop: Producer vs. Consumer Perspectives

Let's visualize the CDC flow for an event-driven system:

  1. Consumer Defines Expectations:

    • The OrderService (consumer) needs to process OrderCreated events from the ShopService (producer).
    • The OrderService developer writes an automated test that defines the shape and content of the OrderCreated event it expects. This includes the event type, specific fields, their types, and example values.
    • This test runs, and if successful, generates a "Pact file" – a JSON representation of this contract.
  2. Pact File Publication:

    • The generated Pact file is published to a central repository, typically a Pact Broker. This makes the contract discoverable by the producer.
  3. Producer Verifies Contracts:

    • The ShopService (producer) developer configures their build pipeline to pull all relevant Pact files (contracts) from the Pact Broker.
    • The ShopService then runs a "Pact verification" step. This step effectively checks if the events it actually produces conform to all the contracts defined by its consumers.
    • If the producer fails to meet any contract, the build breaks, signaling a potential breaking change before deployment.

This elegant loop ensures that both sides are always in sync regarding their expectations, providing a safety net for continuous evolution.

Introducing Pact: The Contract Testing Framework

Pact is a powerful open-source framework specifically designed for Consumer-Driven Contract testing. While it's widely known for HTTP API testing, Pact also provides excellent support for asynchronous message-based interactions, making it perfect for Kafka-driven microservices.

Key Pact Concepts

  • Pact: The contract itself, typically a JSON file. It describes an interaction between a consumer and a provider, detailing the expected request (or message) and the anticipated response (or message content).
  • Consumer: The service that initiates the interaction (e.g., listens to a Kafka topic). It defines what it expects.
  • Provider: The service that responds to the interaction (e.g., publishes to a Kafka topic). It verifies that it meets consumer expectations.
  • Mock Service (Consumer Side): During a consumer test, Pact sets up a mock provider (or mock message queue) that behaves exactly as the consumer expects the real provider to. The consumer application interacts with this mock, and Pact records the interaction into a Pact file.
  • Pact Verifier (Provider Side): During a provider test, the Pact Verifier uses the generated Pact files to replay the expected interactions against the actual provider service. It ensures the provider's behavior matches the contract.
  • Pact Broker: A central repository for storing, publishing, and retrieving Pact files. It's crucial for facilitating the communication between consumers and providers, especially in larger microservice landscapes. The Broker also provides powerful "can-i-deploy" functionality and webhook capabilities.

Pact's Role in Event-Driven Systems

For event-driven architectures, Pact uses "Message Pacts." Instead of defining HTTP requests and responses, you define the expected structure and content of a Kafka message.

Consumer Side: The consumer test defines what message it expects to receive (e.g., an OrderCreatedEvent with specific fields like orderId, customerId, items, etc.). Pact captures this expectation.

Producer Side: The producer test then verifies that when it actually publishes an OrderCreatedEvent, its structure and content match what the consumer specified in the Pact.

This mechanism ensures that any change to an event's payload (adding/removing fields, changing types, etc.) will be flagged immediately if it violates an active consumer contract.

Setting Up Your Spring Boot Project for Pact

Let's walk through integrating Pact into a Spring Boot 4.0 (Java 25) project. We'll set up two hypothetical services: ShopService (the event producer) and OrderService (the event consumer).

1. Project Setup (Maven)

First, add the necessary Pact JVM dependencies to your consumer and producer services' pom.xml.

<!-- In Consumer (OrderService) and Producer (ShopService) pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.0</version> <!-- Assuming Spring Boot 4.0.0 -->
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>your-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>your-service</name>
    <description>Demo project for Spring Boot and Kafka with Pact</description>

    <properties>
        <java.version>25</java.version> <!-- Targeting Java 25 -->
        <pact.version>4.3.10</pact.version> <!-- Use the latest stable Pact JVM version -->
        <testcontainers.version>1.20.0</testcontainers.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Testing dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>au.com.dius.pact.provider</groupId>
            <artifactId>junit5</artifactId>
            <version>${pact.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>au.com.dius.pact.consumer</groupId>
            <artifactId>java8</artifactId>
            <version>${pact.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>au.com.dius.pact.matchers</groupId>
            <artifactId>generators</artifactId>
            <version>${pact.version}</version>
            <scope>test</scope>
        </dependency>
        <!-- Testcontainers for Kafka and PostgreSQL setup during tests -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>kafka</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <!-- Add Maven Surefire Plugin to ensure JUnit 5 tests are picked up -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.2.5</version> <!-- Use a recent version supporting JUnit 5 -->
                <configuration>
                    <skipTests>${skipTests}</skipTests>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-failsafe-plugin</artifactId>
                <version>3.2.5</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>integration-test</goal>
                            <goal>verify</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Explanation:

  • pact.version: Latest stable Pact JVM version.
  • junit5: For Pact provider verification with JUnit 5.
  • java8: For Pact consumer contract definition.
  • generators: For dynamic data generation in contracts.
  • Testcontainers: Essential for setting up isolated Kafka and PostgreSQL instances for integration and contract tests, providing a clean environment every time. This aligns with modern testing practices for Java 25 applications.
  • maven-surefire-plugin and maven-failsafe-plugin: These are configured to run unit tests and integration tests respectively, which is standard practice for separating different types of tests. Pact verification tests typically run as integration tests.

Implementing a Kafka Consumer Test with Pact (Consumer Side)

Let's assume our OrderService (consumer) needs OrderCreatedEvent messages from the ShopService (producer). The OrderService expects a specific JSON structure for this event.

1. Define the Event Class (in OrderService)

// OrderService: src/main/java/com/example/orderservice/events/OrderCreatedEvent.java
package com.example.orderservice.events;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

public record OrderCreatedEvent(
        UUID orderId,
        UUID customerId,
        Instant createdAt,
        List<OrderItem> items,
        String deliveryAddress,
        double totalAmount // 총 금액 (total amount)
) {
    public record OrderItem(
            String productId,
            int quantity,
            double unitPrice // 단가 (unit price)
    ) {}
}

Using Java 25's record for immutable event data is clean and concise.

2. Write the Consumer Pact Test (in OrderService)

This test will define the contract for OrderCreatedEvent that OrderService expects. We'll use PactMessageBuilder for Kafka messages.

// OrderService: src/test/java/com/example/orderservice/pact/OrderServiceConsumerPactTest.java
package com.example.orderservice.pact;

import au.com.dius.pact.consumer.MessagePactBuilder;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.annotations.Pact;
import au.com.dius.pact.core.model.messaging.Message;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import static au.com.dius.pact.consumer.dsl.Matchers.*;
import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ShopService", pactVersion = PactTestFor.PactVersion.V3)
@ActiveProfiles("test") // For test-specific configurations
public class OrderServiceConsumerPactTest {

    private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());

    @Pact(consumer = "OrderService")
    public MessagePact orderCreatedEventPact(MessagePactBuilder builder) {
        // Define the expected message structure (메시지 구조 정의)
        Map<String, String> metadata = new HashMap<>();
        metadata.put("Content-Type", "application/json");

        return builder
                .given("OrderCreatedEvent exists") // Given state (주어진 상태)
                .expectsToReceive("an OrderCreatedEvent from ShopService")
                .withMetadata(metadata)
                .withContent(jsonBody()
                        .uuid("orderId", UUID.randomUUID()) // 주문 ID (order ID)
                        .uuid("customerId", UUID.randomUUID()) // 고객 ID (customer ID)
                        .datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'", "2026-07-26T10:00:00.123456789Z") // 생성 시각 (created at)
                        .array("items") // 상품 목록 (item list)
                            .object()
                                .stringType("productId", "PROD-ABC-123") // 상품 ID (product ID)
                                .integerType("quantity", 2) // 수량 (quantity)
                                .decimalType("unitPrice", 29.99) // 단가 (unit price)
                            .closeObject()
                            .object()
                                .stringType("productId", "PROD-DEF-456")
                                .integerType("quantity", 1)
                                .decimalType("unitPrice", 15.50)
                            .closeObject()
                        .closeArray()
                        .stringType("deliveryAddress", "123 Main St, Anytown, USA") // 배송 주소 (delivery address)
                        .decimalType("totalAmount", 75.48) // 총 금액 (total amount)
                        .build())
                .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "orderCreatedEventPact")
    void testOrderCreatedEventConsumer(Message message) throws JsonProcessingException {
        // Simulate consuming the message (메시지 소비 시뮬레이션)
        String eventJson = new String(message.getContents().valueAsBytes());
        
        // Deserialize and assert the content matches expectations (콘텐츠 역직렬화 및 검증)
        // In a real scenario, you'd feed this to your Kafka consumer logic
        assertThat(eventJson).isNotNull();
        assertThat(message.getMetadata()).containsEntry("Content-Type", "application/json");

        // Example: deserialize into the expected record
        OrderCreatedEvent orderCreatedEvent = objectMapper.readValue(eventJson, OrderCreatedEvent.class);

        assertThat(orderCreatedEvent.orderId()).isNotNull();
        assertThat(orderCreatedEvent.customerId()).isNotNull();
        assertThat(orderCreatedEvent.createdAt()).isNotNull();
        assertThat(orderCreatedEvent.items()).hasSize(2);
        assertThat(orderCreatedEvent.items().get(0).productId()).isEqualTo("PROD-ABC-123");
        assertThat(orderCreatedEvent.totalAmount()).isEqualTo(75.48); // 정확한 금액 검증 (exact amount verification)
        // More assertions based on your consumer's actual logic
    }
}

Key points for the consumer test:

  • @ExtendWith(PactConsumerTestExt.class): Integrates Pact with JUnit 5.
  • @PactTestFor(providerName = "ShopService", pactVersion = PactTestFor.PactVersion.V3): Specifies the provider this consumer interacts with and the Pact version.
  • @Pact(consumer = "OrderService"): Marks the method that defines the consumer's contract.
  • MessagePactBuilder: Used for defining asynchronous message contracts.
  • jsonBody() and Matchers: Pact's DSL for defining flexible JSON structures, allowing type matching (stringType, integerType, uuid, datetime, decimalType) rather than strict value matching, which is crucial for dynamic data.
  • given("OrderCreatedEvent exists"): Defines a "provider state." This is a descriptive string that the producer can use to set up its environment before verification.
  • testOrderCreatedEventConsumer(Message message): This method is where your consumer-side logic would process the mock message generated by Pact. You parse the message and assert that it has the expected structure and content, essentially testing your deserialization and initial processing logic. This is not testing Kafka directly, but the consumer's ability to handle the contracted message.

When this test runs, Pact generates orderservice-shopservice.json (or similar) in target/pacts. This file represents the contract.

3. Build and Publish the Pact File

After the consumer tests pass, the generated Pact file needs to be published to the Pact Broker. This is typically done as part of the consumer's CI/CD pipeline.

You can use the pact-maven-plugin or pact-gradle-plugin to automate this.

<!-- In Consumer (OrderService) pom.xml - Add to <build><plugins> -->
<plugin>
    <groupId>au.com.dius.pact.provider</groupId>
    <artifactId>pact-maven-plugin</artifactId>
    <version>${pact.version}</version>
    <configuration>
        <pactDirectory>${project.build.directory}/pacts</pactDirectory>
        <pactBrokerUrl>http://localhost:9292</pactBrokerUrl> <!-- Replace with your Pact Broker URL -->
        <pactBrokerToken>${PACT_BROKER_TOKEN}</pactBrokerToken> <!-- If authentication is needed -->
        <projectVersion>${project.version}</projectVersion>
        <tags>
            <tag>latest</tag>
            <tag>feature/${env.BRANCH_NAME}</tag> <!-- Example for feature branches -->
        </tags>
    </configuration>
    <executions>
        <execution>
            <id>publish-pacts</id>
            <phase>post-integration-test</phase> <!-- Or deploy/verify -->
            <goals>
                <goal>publish</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Running mvn clean install (or a specific profile that runs the publish goal) after your consumer tests will publish the pact to the broker.

Implementing a Kafka Producer Test with Pact (Producer Side)

Now, the ShopService (producer) needs to verify that it meets the contract defined by the OrderService (consumer).

1. The Producer Event Class (in ShopService)

// ShopService: src/main/java/com/example/shopservice/events/OrderCreatedEvent.java
package com.example.shopservice.events;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

// This should ideally be a shared module or a copy of the consumer's event definition
public record OrderCreatedEvent(
        UUID orderId,
        UUID customerId,
        Instant createdAt,
        List<OrderItem> items,
        String deliveryAddress,
        double totalAmount // 총 주문 금액 (total order amount)
) {
    public record OrderItem(
            String productId,
            int quantity,
            double unitPrice // 상품 단가 (product unit price)
    ) {}
}

2. The Producer's Kafka Publisher (in ShopService)

// ShopService: src/main/java/com/example/shopservice/kafka/OrderEventProducer.java
package com.example.shopservice.kafka;

import com.example.shopservice.events.OrderCreatedEvent;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class OrderEventProducer {

    private static final String ORDER_TOPIC = "order-events"; // 주문 이벤트 토픽 (order event topic)
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    public OrderEventProducer(KafkaTemplate<String, String> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
        this.objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
    }

    public CompletableFuture<Void> publishOrderCreatedEvent(OrderCreatedEvent event) {
        try {
            String eventJson = objectMapper.writeValueAsString(event);
            // 비동기 전송 (asynchronous send)
            return kafkaTemplate.send(ORDER_TOPIC, event.orderId().toString(), eventJson)
                    .completable()
                    .thenApply(sendResult -> {
                        System.out.println("Published OrderCreatedEvent for order: " + event.orderId());
                        return null;
                    });
        } catch (Exception e) {
            System.err.println("Failed to publish OrderCreatedEvent: " + e.getMessage());
            return CompletableFuture.failedFuture(e);
        }
    }
}

3. Write the Producer Pact Verification Test (in ShopService)

This test will pull contracts from the Pact Broker and verify that ShopService produces events that satisfy them.

// ShopService: src/test/java/com/example/shopservice/pact/ShopServicePactVerificationTest.java
package com.example.shopservice.pact;

import au.com.dius.pact.provider.MessageAndMetadata;
import au.com.dius.pact.provider.PactVerifyProvider;
import au.com.dius.pact.provider.junit5.AmpqMessageTestTarget;
import au.com.dius.pact.provider.junit5.HttpTestTarget; // Can also be used for REST endpoints
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import au.com.dius.pact.provider.junitsupport.loader.VersionSelector;
import com.example.shopservice.events.OrderCreatedEvent;
import com.example.shopservice.events.OrderCreatedEvent.OrderItem;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;

// Define the provider name (프로바이더 이름 정의)
@Provider("ShopService")
// Configure Pact Broker to fetch contracts (Pact Broker에서 계약 가져오기 설정)
@PactBroker(url = "http://localhost:9292",
        authentication = @au.com.dius.pact.provider.junitsupport.loader.PactBroker.Authentication(token = "${PACT_BROKER_TOKEN}"),
        consumerVersionSelectors = {@VersionSelector(tag = "latest")}) // Only verify latest tagged pacts
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // Start app on random port for HTTP/REST
@ActiveProfiles("test")
public class ShopServicePactVerificationTest {

    @LocalServerPort
    private int port; // HTTP Port if you have REST endpoints to verify

    @Autowired
    private ObjectMapper objectMapper; // Spring Boot's ObjectMapper (스프링 부트의 ObjectMapper)

    @BeforeEach
    void setUp(PactVerificationContext context) {
        // For HTTP endpoints, set the target
        // context.setTarget(new HttpTestTarget("localhost", port));

        // For Message Pacts, set the target to Message (메시지 팩트를 위해 타겟을 메시지로 설정)
        context.setTarget(new AmpqMessageTestTarget()); // AMPQ is a generic message target
        
        // Ensure ObjectMapper handles Instant (Instant 처리 설정)
        objectMapper.registerModule(new JavaTimeModule()); 
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void pactVerificationTest(PactVerificationContext context) {
        context.verifyInteraction();
    }

    // This method produces the actual message content for verification (검증을 위한 실제 메시지 콘텐츠 생성)
    @PactVerifyProvider("an OrderCreatedEvent from ShopService")
    public MessageAndMetadata verifyOrderCreatedEvent() {
        // Here, you would call your actual event generation logic (실제 이벤트 생성 로직 호출)
        // For demonstration, we'll create a hardcoded event that matches the consumer's contract.
        // In a real application, this would come from your domain service.

        UUID orderId = UUID.fromString("a1b2c3d4-e5f6-7890-1234-567890abcdef");
        UUID customerId = UUID.fromString("b1c2d3e4-f5a6-7890-1234-567890abcdef");
        Instant createdAt = Instant.parse("2026-07-26T10:00:00.123456789Z"); // Matching consumer's datetime format

        List<OrderItem> items = List.of(
                new OrderItem("PROD-ABC-123", 2, 29.99),
                new OrderItem("PROD-DEF-456", 1, 15.50)
        );
        String deliveryAddress = "123 Main St, Anytown, USA";
        double totalAmount = 75.48; // (2 * 29.99) + (1 * 15.50) = 59.98 + 15.50 = 75.48

        OrderCreatedEvent event = new OrderCreatedEvent(
                orderId,
                customerId,
                createdAt,
                items,
                deliveryAddress,
                totalAmount
        );

        try {
            String jsonEvent = objectMapper.writeValueAsString(event);
            Map<String, String> metadata = Map.of("Content-Type", "application/json"); // 메타데이터 일치 (metadata match)
            return new MessageAndMetadata(jsonEvent.getBytes(), metadata);
        } catch (Exception e) {
            throw new RuntimeException("Failed to produce OrderCreatedEvent for Pact verification", e);
        }
    }

    // You can add more @PactVerifyProvider methods for other events/interactions
}

Key points for the producer test:

  • @Provider("ShopService"): Identifies this service as the provider.
  • @PactBroker: Configures the producer to fetch Pact files from a Pact Broker URL. consumerVersionSelectors = {@VersionSelector(tag = "latest")} is crucial to ensure you're only verifying the most relevant contracts.
  • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT): Starts the Spring Boot application context, crucial if your provider logic relies on Spring components (e.g., ObjectMapper, KafkaTemplate).
  • AmpqMessageTestTarget(): Configured in @BeforeEach to tell Pact to verify message-based interactions.
  • @PactVerifyProvider("an OrderCreatedEvent from ShopService"): This method's string argument must match the expectsToReceive string from the consumer's Pact definition. Inside this method, you simulate your service producing the event. This might involve calling a service method that generates an OrderCreatedEvent and then serializing it. Pact will then compare this actual serialized output against the expected contract.
  • The objectMapper is vital for correctly serializing the Java Instant records into the JSON format expected by the consumer.

When this test runs, it downloads the Pact file(s) from the Broker, sets up your ShopService provider, and then verifies that the messages produced by your verifyOrderCreatedEvent method conform to the contracts defined in those Pact files. If any contract is violated (e.g., a field is missing, type is wrong, or format doesn't match), the test will fail, preventing a breaking change from reaching production.

The Role of the Pact Broker

The Pact Broker is the glue that connects consumers and producers in a CDC workflow. It's a critical component for managing contracts in a distributed microservice environment.

What is it? Why use it?

The Pact Broker is a service that:

  1. Stores Pact files: Consumers publish their contracts to it.
  2. Makes Pacts discoverable: Producers can query it to find all contracts they need to verify.
  3. Facilitates can-i-deploy: A crucial feature allowing you to ask the Broker, "Can I deploy version X of service Y? Have all my consumers successfully verified this version?"
  4. Provides Webhooks: Triggers events (e.g., notifying producers when a new consumer contract is published, or notifying consumers when a producer passes verification).

Without a Pact Broker, you'd have to manually copy Pact files between projects, which quickly becomes unmanageable.

Publishing Pacts and can-i-deploy Workflow

The standard workflow involving the Pact Broker is:

  1. Consumer builds and publishes a Pact:
    # In consumer's CI/CD
    mvn clean install
    mvn pact:publish -Dpact.broker.publish=true # Or similar command
    
  2. Producer verifies Pacts:
    # In producer's CI/CD
    mvn clean install # This will run the Pact verification tests
    
  3. Producer indicates successful verification: After a successful verification run, the producer publishes its verification results back to the Pact Broker. This is automatically done by the Pact Maven plugin when verification passes.
  4. can-i-deploy check: Before deploying the producer (or consumer), a can-i-deploy check is performed against the Pact Broker.
    # Example using Pact CLI
    pact-broker can-i-deploy --pacticipant ShopService --version 1.0.0 --to production
    
    This command checks if all active consumer contracts for ShopService version 1.0.0 have been successfully verified by ShopService and if ShopService 1.0.0 has verified all consumer contracts.

Setting up a Dockerized Pact Broker

Running a Pact Broker locally or in your CI environment is straightforward using Docker.

Multi-OS Mapping Table: Running Pact Broker with Docker

Operating SystemCommand to Run Pact BrokerDescription
Windows (PowerShell)docker run -p 9292:9292 -e PACT_BROKER_DATABASE_ADAPTER=sqlite -e PACT_BROKER_LOG_LEVEL=DEBUG --name pact-broker-db pactbroker/pact-broker:latestStarts Pact Broker with SQLite for simplicity, mapping port 9292. pact-broker-db is the container name.
macOS (Terminal)docker run -p 9292:9292 -e PACT_BROKER_DATABASE_ADAPTER=sqlite -e PACT_BROKER_LOG_LEVEL=DEBUG --name pact-broker-db pactbroker/pact-broker:latestIdentical to Linux.
Linux (Terminal)docker run -p 9292:9292 -e PACT_BROKER_DATABASE_ADAPTER=sqlite -e PACT_BROKER_LOG_LEVEL=DEBUG --name pact-broker-db pactbroker/pact-broker:latestStarts the Pact Broker using SQLite as the database, logging debug messages, and exposes it on port 9292.

Important Notes:

  • For production environments, you'd typically use a robust database like PostgreSQL for the Pact Broker. The command would change to something like:
    docker run -p 9292:9292 \
        -e PACT_BROKER_DATABASE_ADAPTER=postgres \
        -e PACT_BROKER_DATABASE_HOST=<your-postgres-host> \
        -e PACT_BROKER_DATABASE_USERNAME=<user> \
        -e PACT_BROKER_DATABASE_PASSWORD=<password> \
        -e PACT_BROKER_DATABASE_NAME=<db-name> \
        --name pact-broker-db pactbroker/pact-broker:latest
    
  • The Pact Broker UI is accessible at http://localhost:9292. This UI allows you to visualize your services, their contracts, and verification results.
  • Authentication: For a secure Pact Broker, configure authentication (e.g., basic auth, OAuth) and pass credentials to your Maven plugin configurations.

Advanced Scenarios and Best Practices

Mastering CDC with Pact involves more than just basic message contract definitions.

Handling Evolving Event Schemas

The "Evolving Contract: Mastering Event Schema Evolution with Kafka, Avro, and Spring Boot" post covers how to manage schema changes using Avro. Pact complements this beautifully. While Avro ensures backward/forward compatibility at a schema level, Pact goes further by verifying the content and structure that a consumer actually expects from that schema.

  • Combine Avro with Pact: Use Avro for strict schema definition and compatibility checks during deserialization. Use Pact to ensure the data values and presence of fields within those schemas meet consumer expectations.
  • Version your Pacts: As your event contracts evolve, ensure your consumers generate new versions of their Pact files. The Pact Broker can manage these versions, allowing producers to verify against multiple historical consumer versions if needed for backward compatibility.
  • Provider States: Utilize provider states effectively. When a consumer defines a contract, it can specify a "given" state (e.g., given("OrderCreatedEvent with items and total amount exists")). The producer then implements a setup for this state in its verification test to ensure the generated event context matches the consumer's expectation. This is critical for complex event scenarios.

Versioning Contracts

Pact's consumerVersionSelectors are powerful for managing multiple versions:

  • @VersionSelector(tag = "latest"): Verifies against the latest published contract for each consumer. Ideal for continuous deployment to ensure immediate compatibility.
  • @VersionSelector(branch = "main"): Verifies against contracts published from the main branch.
  • @VersionSelector(matchingBranch = true): Matches the producer's branch name to consumer contract branches.
  • @VersionSelector(all = true): Verifies against all known contracts (use with caution, can be very slow).

For event-driven systems, judicious use of tags (e.g., latest, production-candidate) and branch-based selection ensures your producer verifies only the relevant consumer contracts.

Message Pacts vs. HTTP Pacts

This guide focuses on Message Pacts for Kafka, but Pact also handles HTTP Pacts for REST/gRPC interactions. If your microservice uses both, you'd have both consumer-side HTTP and Message Pact tests, and your producer would have both HttpTestTarget and AmpqMessageTestTarget in its verification. The core principles remain the same.

Dealing with Complex Event Payloads

  • Pact Matchers: Leverage Pact's extensive matchers (like, eachLike, type, regex, datetime, uuid) to define contracts flexibly. Avoid strict value matching unless truly necessary. For example, use uuid("orderId", UUID.randomUUID()) instead of a fixed UUID. This makes tests robust against dynamic data.
  • Generators: Pact allows you to define generators for data within your contracts. This is useful for creating dynamic test data that still conforms to the contract.
  • Nested Objects and Arrays: Pact's DSL (object(), array()) makes it easy to define deeply nested JSON structures accurately.

Integrating into CI/CD Pipelines

CDC's true power is unleashed when integrated into your CI/CD pipeline.

Consumer Pipeline:

  1. Run unit tests.
  2. Run consumer Pact tests (generating Pact files).
  3. Publish Pact files to Pact Broker (e.g., using pact-maven-plugin:publish).
  4. Optionally, use pact-broker can-i-deploy to check if the consumer can deploy given the current producer versions.

Producer Pipeline:

  1. Run unit tests.
  2. Pull Pact files from Pact Broker based on consumerVersionSelectors.
  3. Run producer Pact verification tests (e.g., mvn failsafe:integration-test which triggers @PactVerificationInvocationContextProvider).
  4. Publish verification results back to Pact Broker.
  5. Use pact-broker can-i-deploy to determine if the producer can deploy its new version based on all consumer verifications. If this fails, the deployment is blocked.

This ensures that no breaking changes sneak into production.

Consumer-Driven vs. Provider-Driven Contracts (Briefly)

While CDC is generally preferred, sometimes a Provider-Driven Contract approach is necessary, especially when integrating with third-party APIs or legacy systems where you cannot influence the consumer's testing. In such cases, the producer defines the contract, and consumers use tools like OpenAPI/Swagger Codegen to generate client stubs. Even then, Pact can still be useful for the consumer to verify that the generated client works as expected with the actual provider. However, for internal microservices, CDC provides superior decoupling and safety.

Troubleshooting / What if it doesn't work?

Even with a robust framework like Pact, issues can arise. Here are common pitfalls and debugging strategies:

  1. Pact Broker Connection Issues:

    • Symptom: Connection refused, Host unreachable, 401 Unauthorized.
    • Check: Is the Pact Broker running? Is it accessible from your build environment? Is the URL in your Maven plugin and @PactBroker annotation correct? Are you providing the correct authentication token if required (PACT_BROKER_TOKEN environment variable or direct config)?
    • Solution: Verify network connectivity. Ensure PACT_BROKER_TOKEN is set and correct.
  2. Mismatched Contract Definitions:

    • Symptom (Producer): Pact verification fails with messages like "Expected field 'X' to be present but was missing," or "Expected 'Y' to be of type String but was Number."
    • Check: The most common cause. Compare the Pact file (found in target/pacts after consumer test, or viewable on Pact Broker) with the actual message JSON generated by your producer's @PactVerifyProvider method. Look for differences in field names, types, and structure.
    • Solution: Adjust either the consumer's expectation in its Pact test or the producer's event generation logic. Remember, the consumer drives the contract!
  3. Incorrect Provider States:

    • Symptom (Producer): Verification fails because the event generated in @PactVerifyProvider doesn't match the specific conditions expected by the consumer.
    • Check: Does the given() state in the consumer's Pact method accurately describe what your producer needs to set up? Is the producer's @PactVerifyProvider method generating an event that satisfies that specific state?
    • Solution: Refine provider states and ensure the producer's test setup correctly mimics the required state.
  4. Date/Time Format Mismatches:

    • Symptom: datetime matchers fail, or deserialization errors occur for Instant or LocalDateTime.
    • Check: Are both consumer and producer using the exact same date/time format string in Pact matchers and ObjectMapper configuration? ISO-8601 with fractional seconds (e.g., "yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSS'Z'") is highly recommended for Instant. Ensure objectMapper.registerModule(new JavaTimeModule()); is present on both sides.
    • Solution: Standardize date/time serialization/deserialization across services.
  5. Pact Test Not Running/Skipped:

    • Symptom: No Pact files generated (consumer) or no verification attempts (producer).
    • Check: Are your JUnit 5 annotations correct (@ExtendWith, @PactTestFor, @Pact, @Provider, @PactBroker, @TestTemplate, @ExtendWith(PactVerificationInvocationContextProvider.class))? Is your Maven/Gradle build configured to run integration tests (e.g., maven-failsafe-plugin)?
    • Solution: Review Pact JVM documentation for JUnit 5 setup. Ensure your build tool is executing integration tests.
  6. "Can I Deploy" Failures:

    • Symptom: pact-broker can-i-deploy command returns a non-zero exit code or "No" result.
    • Check: Have all required consumers successfully published their Pacts? Has the current version of the producer successfully verified all these Pacts, and published those verification results to the Broker? Are your consumerVersionSelectors in @PactBroker correctly filtering the desired contracts?
    • Solution: Debug the failed verifications first. Ensure all relevant consumers have working pipelines that publish Pacts. Ensure the producer's pipeline is completing verification and publishing results.

By systematically going through these checks, you can quickly identify and resolve most Pact-related issues, ensuring your CDC strategy remains effective and provides the confidence it's designed for.

Conclusion

Mastering Consumer-Driven Contracts with Pact is not just another testing technique; it's a fundamental shift in how you approach integration in event-driven microservice architectures. By empowering your consumers to define the expected contracts, you establish a robust safety net that prevents breaking changes, accelerates independent deployments, and fosters genuine confidence in your distributed systems.

Leveraging Java 25, Spring Boot 4.0, and Apache Kafka, your teams can now build, test, and deploy features with unparalleled speed and reliability. The journey might involve a learning curve, but the investment in CDC pays dividends by significantly reducing the cost and risk associated with evolving complex backend systems. Embrace Pact, integrate it into your CI/CD, and watch your event-driven Spring Boot microservices thrive with resilience and autonomy. Start implementing CDC today, and transform your microservice development experience.


🔍 Deep-Dive Search Index & Tags

Developer Intent & Synonyms: Consumer-Driven Contracts, CDC, Pact, Kafka Contract Testing, Spring Boot Contract Testing, Microservices Integration Testing, Event-Driven Contract Testing, Java 25 Pact, Breaking Changes Prevention, Asynchronous Microservice Testing, Pact Broker, Pact JVM, 메시지 계약 테스트, 컨슈머 주도 계약, 카프카 마이크로서비스 테스트, 스프링 부트 통합 테스트, 팩트 브로커, 분산 시스템 테스트, 이벤트 기반 아키텍처, 계약 검증, API 계약 테스트, 신뢰할 수 있는 배포, 독립적 배포.