In this article, we're going to explore Contract Testing
Contract testing ensures that event contracts remain stable as systems evolve. Producers receive immediate feedback when changes affect consumers, and consumers gain confidence in the data they process.
16. Why Contract Testing Matters
Events act as public contracts, and those contracts evolve as systems grow. The key question is how a producer can verify that a schema change does not break its consumers.
Many teams rely on integration testing for this purpose. While useful, integration tests only confirm that systems can communicate. They do not guarantee that all consumers still understand the event contract.
Consider an Order Service publishing an OrderConfirmed event consumed by multiple services:
OrderConfirmed
|
+------------------+-------------------+
| | |
v v v
Inventory Billing Notification
If the producer renames a field:
{
"customerId": "CUS-501"
}
to:
{
"accountId": "CUS-501"
}
everything may still appear correct. The code compiles, tests pass, and deployment succeeds. However, the Billing Service may fail at runtime because it still expects customerId. The producer had no visibility into this dependency.
The issue is not deployment failure but contract violation. Contract testing exists to detect such issues before they reach production.
- Integration Tests Cannot Protect Unknown Consumers
Integration tests typically validate interactions between known systems:
Order Service
|
v
Inventory Service
In this setup, both systems are controlled and predictable. Event-driven systems differ because consumers are loosely coupled and may not be known to the producer.
A new Analytics Service might start consuming OrderConfirmed months later. The producer cannot manually validate every consumer. Contract testing addresses this by validating expectations rather than communication.
17. Consumer-Driven Contracts
Traditional API testing starts with the producer defining the contract. Consumers adapt to it. Consumer-driven contract testing reverses this approach.
Consumers define their expectations, and producers verify that they continue to meet them. This model works well in event-driven systems where consumers evolve independently.
- Thinking From the Consumer's Perspective
Different consumers often require different subsets of data. For example, the Billing Service may need:
{
"orderId": "ORD-1001",
"customerId": "CUS-501",
"totalAmount": 249.99
}
The Notification Service may require:
{
"orderId": "ORD-1001",
"customerId": "CUS-501",
"customerEmail": "alice@example.com"
}
The producer does not need to understand each consumer’s logic. It only needs to satisfy their declared contracts. This encourages careful evolution and explicit documentation of expectations.
- A Contract Is More Than Field Names
Contracts define more than structure. They also capture meaning and constraints.
For example:
{
"orderId": "ORD-1001",
"totalAmount": 249.99
}
A robust contract may specify:
orderId must exist and not be empty
totalAmount must be numeric
totalAmount must be greater than zero
These rules ensure data integrity and provide stronger guarantees for both producers and consumers.
18. Contract Testing in Practice
Consider an Order Service publishing:
{
"eventType": "OrderConfirmed",
"eventVersion": "1.0",
"payload": {
"orderId": "ORD-1001",
"customerId": "CUS-501",
"currency": "USD",
"totalAmount": 249.99
}
}
Consumers depend on different fields:
Billing: orderId, customerId, totalAmount
Inventory: orderId
Notification: customerId
If the producer changes the schema:
{
"payload": {
"orderId": "ORD-1001",
"accountId": "CUS-501",
"currency": "USD",
"totalAmount": 249.99
}
}
the system may still compile and deploy. However, contract validation fails because consumer expectations are no longer met. The issue is detected before release, preventing runtime failures.
- The Development Workflow
Contract testing integrates into the delivery pipeline:
Consumer Defines Contract
|
v
Producer Validates Contract
|
v
Build Succeeds
|
v
Deploy
Every schema change is validated during the build process, ensuring compatibility before deployment.
19. Using JSON Schema to Describe Events
JSON Schema provides a structured way to define event contracts. It acts as an executable specification shared by producers and consumers.
Consider a sample schema:
{
"type": "object",
"required": [
"orderId",
"customerId",
"totalAmount"
],
"properties": {
"orderId": {
"type": "string"
},
"customerId": {
"type": "string"
},
"totalAmount": {
"type": "number"
}
}
}
This schema defines required fields, types, and structure. Additional constraints can include string lengths, numeric ranges, formats, and enumerations. Unlike static documentation, schemas can be validated automatically.
- Validation During Publishing
Producers can validate events before publishing:
OrderConfirmedEvent event = ...
validator.validate(event);
eventPublisher.publish(event);
Invalid events are rejected early, preventing faulty data from entering the system.
- Validation During Consumption
Consumers can also validate incoming events:
OrderConfirmedEvent event = ...
validator.validate(event);
process(event);
This ensures that only valid data reaches business logic, improving reliability.
20. Schema Registries and Centralized Contracts
As systems scale, managing schemas across multiple repositories becomes difficult. Teams may define similar events differently, leading to inconsistencies.
A centralized schema repository addresses this:
Event Schemas
|
+------------+------------+
| |
v v
Producers Consumers
This repository becomes the single source of truth. Producers publish against registered schemas, and consumers validate against them.
- Why Centralized Schemas Help
Centralized schema management improves consistency and visibility. Contracts become discoverable, version history is preserved, and compatibility rules can be enforced automatically.
Teams spend less time interpreting payloads and more time building features. The contract becomes a shared organizational asset rather than an isolated implementation detail.
21. Common Contract Testing Mistakes
Several common issues arise when adopting contract testing.
- Treating Documentation as the Contract
Documentation often becomes outdated as implementations change. Consumers may rely on incorrect assumptions. Executable contracts remain synchronized with the system and provide reliable validation.
- Testing Only Happy Paths
Contracts should cover more than valid scenarios. They should include:
- missing required fields
- invalid data types
- unexpected values
- optional fields
- deprecated fields
This ensures consumers handle both valid and invalid inputs correctly.
- Ignoring Backward Compatibility
Passing current contracts does not guarantee future compatibility. Schema evolution must be validated alongside contract correctness to avoid breaking existing consumers.
- Assuming Producers Own the Contract
Although producers publish events, contracts are shared responsibilities. Both producers and consumers must maintain and validate them.
Reliable event-driven systems depend on well-defined, continuously validated contracts.
In the next part, we will explore production practices like idempotency, event ordering, duplicate handling, correlation and observability.
Top comments (1)
The customerId to accountId change in OrderConfirmed is the easy failure mode, because the contract shape actually changes and a structural check has something obvious to catch. The scarier break is when the bytes still look valid while the meaning moves underneath Billing: totalAmount changes from pre-tax to post-tax, or currency keeps the same enum value while its unit convention changes. Every consumer-driven contract can stay green there, since totalAmount is still a positive number and currency is still an allowed value. The validator passes. The invoice is wrong. In an event log I worked on, this class of bug slipped through because each consumer asserted only the fields it read, while the real promise depended on a relationship between line items and the amount. That also makes the later Analytics Service example a bit uncomfortable, since a registry can only protect contracts from consumers that already registered before the producer change. I would treat any semantic reinterpretation as a major eventVersion bump even when the schema is byte-for-byte identical, and make consumers assert the eventVersion they understand. I would also add a golden OrderConfirmed sample where totalAmount is recomputed from the line items, so a pre-tax to post-tax drift breaks a test even though the per-field validation remains green.