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:
-
Consumer Defines Expectations: The
EmailService(the consumer) defines a "contract" for theUserCreatedevent. 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.)
-
Provider Verifies Against Contract: The
UserService(the provider) runs its tests against this contract. It ensures that anyUserCreatedevent it publishes actually matches the fields and types defined in theEmailService’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 endThis verification happens before
UserServiceis deployed. IfUserServicechanges itsUserCreatedevent format (e.g., renamesuserIdtouserIdentifieror removesfirstName), this verification step will fail. -
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
UserServicepublishes a new contract version, the Pact Broker can tellEmailServiceif 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.