Can consumer-driven contracts manage breaking change in microservice integrations?
One of the more enduring problems with microservice integration is managing change in service interfaces. Even with careful coordination it's easy for a team to deploy breaking changes that cripple consumers.
Testing for breaking changes can be difficult once you have a lot of services in flight. It's easy enough for consumers to mock out services that publish some form of schema (e.g. Swagger or similar), but how do they know when these contracts change? You need an untold amount of developer vigilance to maintain symmetry between services, particularly when they are maintained by separate teams.
Versioning does not provide much help here. Maintaining more than one version of a service interface in production places a considerable burden on development teams that can quickly become untenable.
Teams often fall back onto setting up integration testing environments to check that services are compatible before they hit production. As J.B. Rainsberger points out (with considerable relish) these types of integration tests are a time-consuming burden that do not improve the overall quality of a system.
Integration testing environments are complex, fragile and tend require a lot of effort to setup and keep running. It's easy to obtain false negatives that are caused by something other than service incompatibility. An integration test is also the wrong place to test for this kind of problem, as it is relatively costly to track down and fix problems at this late stage in the development process.
Consumer-Driven Contracts to the rescue?
Consumer-driven contracts are a test technique where contracts between services are designed from the perspective of the consumer. These contracts can be used by a service to ensure that any changes in its implementation do not break any consumers.
Consumer-driven contracts are not a new idea but practical implementations are thin on the ground. Pact is one of the more mature cross-platform libraries that has been designed specifically for contract testing on REST-based microservice ecosystems.
Pact allows clients to define their own expectations of an API through a mocked service. These expectations are used to generate a Pact file, which is a JSON-based format for describing API operations. These Pact files have to be piped over to the service that is responsible for them so it can check that it is meeting client requirements.
This turns the whole idea of contract management on its head. The consumers define the expectations that services have to meet as part of their unit tests. The idea is that this provides symmetry between client and service contract tests, doing away with the need for those complex integration test environments.
Does this really solve the problem?
A weakness with this approach is that it creates a swarm of JSON configuration files that need to be piped towards service tests. Pact addresses this by using a broker to distribute the appropriate configuration files to services. This creates a chunk of infrastructure that binds your entire development pipeline into a centralised contract broker.
Most of the advantages of a service-based architecture revolve around being able to isolate functionality into autonomous implementations. Coupling your services together into a web of JSON-based configuration files can only undermine this by creating explicit dependencies between teams.
Arguably, these team dependencies already exist. Consumer-driven contracts just allow them to be formally identified and tested in isolation. Still, allowing teams to define each other's test cases could have some interesting cultural implications. How will a team react when their release is being blocked by somebody else's test cases?
Ultimately, if you want to reduce the impact of breaking change the answer may not lie in binding services together with schemas. At the very least, a more tolerant approach to change could be a smarter way of dealing with the problem.
Turning Postel's law on its head
Postel's law suggests that you should be “conservative in what you do” and “liberal in what you accept from others”. The expectation is that you should always try to follow specifications, but try to accept non-conformant input if the intent can be understood.
This creates an expectation that a service will be tolerant of what it accepts from consumers. What's valuable about this approach is that it helps you to build complex networked systems without coordinating with others or having to ask their permission.
Consumer driven contracts appear to take a different view as services are bound by the expectations of their consumers. This can create a “tyranny of consumers” who are given the right to define the pass conditions for a service's unit tests.
This can make change even more difficult to implement. You are still left with the problem of how to coordinate change between collaborating services, except with the added headache of distributed configuration. You may be signposting breaking changes more efficiently, but services are bound into a system of distributed contracts that can prevent any service from evolving independently.
A one-sided contract
One thing missing in consumer-driven contracts is the notion of acceptance.
When a service controls the contact there is tacit acceptance by both parties when a consumer starts to use it. The service can decide which types of request it wants to support. The consumer agrees to only make requests that the service has agreed to fulfill.
This notion of two-way consent is missing in consumer-driven contracts. A service does not get to choose which consumers will use it, but the consumers can dictate the terms of engagement. The contract becomes a one-sided demand which limits the autonomy of the service. This is a high price to pay for greater visibility of breaking change.