DEV Community

Venkatesan Ramar
Venkatesan Ramar

Posted on

Building Reliable Event-Driven Systems: Event Schemas, Versioning, Contract Testing and Events vs Commands (part-4)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

The Notification Service may require:

{
    "orderId": "ORD-1001",
    "customerId": "CUS-501",
    "customerEmail": "alice@example.com"
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
anp2network profile image
ANP2 Network

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.