Contract testing is your best friend for preventing breaking changes in event-driven systems, and the most surprising thing is how little it actually tests.

Imagine you have a UserCreated event published by a UserService and consumed by an EmailService to send a welcome email.

// Example UserCreated event
{
  "userId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
  "email": "jane.doe@example.com",
  "firstName": "Jane",
  "lastName": "Doe",
  "timestamp": "2023-10-27T10:00:00Z"
}

The EmailService only cares about userId, email, and firstName. It doesn’t need lastName or timestamp to do its job. Contract testing ensures that UserService always provides these three fields in the expected format, and that EmailService always expects them. It doesn’t care if UserService also provides lastName or timestamp, or if EmailService could use them.

Here’s how it works:

  1. Consumer Defines Expectations: The EmailService (the consumer) defines a "contract" for the UserCreated event. This contract specifies the minimum fields it needs and their expected types.

    # Pactfile for EmailService consuming UserCreated
    {
      "consumer": {
        "name": "EmailService"
      },
      "provider": {
        "name": "UserService"
      },
      "interactions": [
        {
          "description": "a request for a user created event",
          "request": {
            "method": "GET", # Or POST, depending on your event ingestion
            "path": "/events/UserCreated"
          },
          "response": {
            "status": 200,
            "headers": {
              "Content-Type": "application/json"
            },
            "body": {
              "userId": "a1b2c3d4-e5f6-7890-1234-567890abcdef",
              "email": "jane.doe@example.com",
              "firstName": "Jane"
            }
          }
        }
      ],
      "metadata": {
        "pactSpecification": {
          "version": "2.0.0"
        }
      }
    }
    

    (Note: For event-driven systems, this contract often represents a published message rather than an HTTP request/response. Tools like Pact can be adapted for this, often by having the consumer mock the producer’s publishing mechanism and asserting the message content.)

  2. Provider Verifies Against Contract: The UserService (the provider) runs its tests against this contract. It ensures that any UserCreated event it publishes actually matches the fields and types defined in the EmailService’s contract.

    # RSpec test in UserService verifying against EmailService's pact
    describe UserService do
      it "publishes UserCreated events matching EmailService's contract" do
        allow_any_instance_of(KafkaProducer).to receive(:publish).with(
          topic: 'user-events',
          message: hash_including(
            userId: String,
            email: String,
            firstName: String
          )
        )
        # ... code that triggers a UserCreated event ...
      end
    end
    

    This verification happens before UserService is deployed. If UserService changes its UserCreated event format (e.g., renames userId to userIdentifier or removes firstName), this verification step will fail.

  3. Pact Broker: The generated contracts (or verification results) are published to a Pact Broker. This acts as a central registry, allowing consumers and providers to discover each other’s compatibility. When UserService publishes a new contract version, the Pact Broker can tell EmailService if its current version is compatible with the latest provider version.

The core problem this solves is integration hell. Without contract testing, you rely on end-to-end (E2E) tests to catch breaking changes. E2E tests are slow, brittle, and often fail for reasons unrelated to the specific integration you’re testing. Contract testing isolates the integration point, giving you fast, reliable feedback specifically on the contract between two services.

The most important levers you control are the granularity of your contracts and the scope of the fields you include. A contract should represent the minimal set of data a consumer needs. Over-specifying fields in a contract unnecessarily ties the provider’s hands and can lead to more false negatives. Conversely, under-specifying means the consumer might break if the provider changes a field it was providing, even if the consumer didn’t explicitly declare it as a requirement.

When a provider changes a message format, it’s easy to think that if the consumer can still process the message (e.g., it ignores unknown fields), then everything is fine. But contract testing forces you to be explicit about what is and isn’t required. If EmailService decides it now needs lastName to personalize the welcome email, it updates its contract. When UserService verifies against this new contract, it will fail if it hasn’t started including lastName. This allows for coordinated evolution, not surprise breakages.

The next challenge is managing contracts across many services and ensuring consumers are always using a compatible provider version.

Want structured learning?

Take the full Event-driven course →