Throughout my career, I’ve come across numerous blogs and lectures on testing event-driven architectures, covering everything from unit tests to full integration phases. A common theme in all these resources is the focus on event brokers, particularly Kafka. Almost every tutorial I’ve encountered references Kafka alongside frameworks like Spring Boot Contract Test or Pact. What ties these approaches together is their reliance on the concept of a Schema Registry.
The Schema Registry is a dedicated service that handles schema management for data sent through Kafka topics, commonly supporting serialization formats like Avro, JSON Schema, or Protobuf. Its key role is to enforce compatibility rules, ensuring that schema changes do not disrupt existing producers or consumers. In other words, in a live environment, any non-backward-compatible schema changes will be immediately rejected by the event broker, preventing potential failures. While this approach works seamlessly within the Kafka ecosystem, most cloud providers, including AWS, do not offer this functionality natively out of the box.
Now that we understand why contract testing may be less critical in systems utilizing the Schema Registry concept, let’s shift our focus to systems that don’t incorporate it. Before we delve into that analysis, it’s essential to discuss the types of contract testing available.
We can distinguish between two main types of contract testing: Producer-Driven Contracts and Consumer-Driven Contracts (often referred to as CDC). Understanding the distinction between these two approaches is crucial, as it fundamentally alters the way we develop our services.
- In a Producer-Driven Contract, the API contract resides on the producer side. The producer determines the design and structure of the API or service, while consumers must adapt to the provider’s specifications. This behavior aligns with the concept of Open Host Service (OHS) in Domain-Driven Design (DDD) context mappings.
- Conversely, in Consumer-Driven Contracts (CDC), the API contracts are defined from the consumer’s perspective. Here, the consumer dictates how the API provided by the producer should be structured. The consumer outlines the expected requests and responses for interacting with the provider. When the API undergoes changes, the consumer creates the new API contract, allowing the provider to implement it. This approach explicitly embodies the Customer/Supplier relationship as defined in DDD context mappings.
For the sake of discussion, let’s consider a system that follows the Producer-Driven Contract approach, as it tends to be simpler. In this scenario, whenever a consumer deploys a new version, it verifies its contracts against the producer’s API contract stub. Similarly, when a producer deploys a new version, server-side contract tests validate its own contract against the established consumer expectations.
Does this problem sound familiar? I’ve encountered numerous instances where individuals, without thorough investigation, automatically modify APIs and tests just to ensure they pass. In these cases, we rely solely on code reviews as our last line of defense. However, since these reviews are conducted by humans, mistakes are inevitable. Unfortunately, I’ve witnessed such issues far more frequently than I’d like to admit. To address this challenge, I propose an automated solution to minimize the risk of these errors.
Imagine we are managing a large microservice application encompassing multiple business domains, including payment and cart domains, as illustrated in the diagram below.
What happens if the Cart team makes a non-backward-compatible change to their contract in their Lambda function? If the tests are not designed with caution, they may succeed in the Cart Lambda while failing in the Payment domain. Given our goal of minimizing end-to-end testing, we lack additional safeguards to protect against such issues. This situation underscores the importance of implementing robust contract testing practices to ensure compatibility across services.
Solution
To ensure that changes are backward compatible, we can run contract tests on the consumers immediately after deploying the producer microservice. In the example below, when the Cart Lambda (producer) modifies its event production process, we can push a new contract or subscription to the schema repository—such as a simple S3 bucket. Subsequently, we can notify all interested consumers to run their contract tests against the newly released version of the contract provided by the producer. This proactive approach guarantees that if any backward-incompatible changes occur—potentially breaking the contract between the Cart Lambda (producer) and the Payment Lambda (consumer)—they will be detected promptly, allowing for immediate rollback or remediation.
In the CI/CD pipeline, the process appears as follows, without explicitly indicating the revert steps:
This pipeline illustrates how this mechanism can be effectively implemented. Whenever a new deployment is made to the producer (Cart Lambda), the pipeline is triggered. It first conducts self-tests, runs server contract tests, releases the updated image, and then deploys the new version. Following this, it initiates a specified number of sub-pipelines for dependent services that require validation. At this stage, it’s essential to maintain a comprehensive list of all services that utilize the contract.
As each sub-pipeline completes, its status is reported back to a central controller, which monitors the entire validation process. If all sub-pipelines pass successfully, the CI/CD pipeline proceeds to the next steps, ensuring a smooth and reliable deployment process.