Managing the dependencies of all services in a microservices landscape is one of the greatest challenges of this architectural approach. Questions about which service uses which interface in which version must be answered. Extensive backward compatibility of the interfaces is therefore mandatory, especially for public APIs.
For the internal APIs, which are only used for communication between microservices, the matter can be approached in a somewhat more relaxed manner: If a part of an API is no longer used by any client, it can be shut down, regardless of whether it is a breaking change or not. The prerequisite for this is to have a way to find out whether an (internal) API is still in use or not. One possible solution is consumer-driven contracts.
Removing old APIs
Typically, the provider of an API specifies and documents it to allow its clients to use the API in a defined way. In the context of consumer-driven contracts, this is called a provider contract.
In practice, however, it is often the case that there is no client that uses the entire breadth of the offered interface. In fact, each client usually uses only a small part of an offered interface. This part is called a consumer contract. The sum of the Consumer Contracts is then what the provider actually has to provide as an interface. Often this part is smaller than the actual Provider Contract offered. In particular, if the provider is still offering old versions of APIs, it is desirable to have a mechanism to detect that there is no client left using them.
The idea behind consumer-driven contracts is to achieve exactly that by having clients continuously submit their consumer contracts to the provider. The provider can then use them to test the interfaces it offers. All interfaces for which there is no current consumer contract can be removed.
But consumer-driven contracts also prove useful when it comes to defining a new interface or extending an interface.
After all, unless a public API is involved, the normal case is that interfaces are changed only because one of the clients needs the change. What could be more obvious than that the client also specifies the change to the interface. It goes without saying that this has to be done in consultation with the provider team that has to implement the interface change later on.
With consumer-driven contracts, such a possibility has been created. The consumer who needs the new interface simply creates a consumer contract and submits it to the provider. If used correctly, this variant offers the opportunity to significantly improve the quality of the exchange between the two teams. Every arrangement is formally recorded in a consumer contract in great detail (on a request/response basis). This also implies, for example, the nature of the test data. Misunderstandings become apparent at the latest when the provider tests the consumer contract with its test pipeline.
The state of the provider
When a request is sent to a server, it will respond differently depending on its state or condition. For servers implemented completely stateless, the state is usually entirely in the database. However, there may be other factors influencing the state, such as the responses of other surrounding systems that are requested by the server.
The handling of the provider state is the major weakness of consumer-driven contracts. In Pact, for example, test data is specified only by a string, the provider state. What data and what behavior is specifically behind this string is not content of the Pact specification and must be specified and documented elsewhere. In Spring Cloud Contract, there is not even that and a fixed test data set, i.e. a unique state, must be assumed.
What consumer-driven contracts cannot replace and what they cannot test is the correct behavior of the application under certain conditions. This must be explicitly tested, communicated and documented by the service provider team. If this is not done, and if developers are not aware of this weakness, consumer-driven contract tests may provide deceptive security: Just because all tests are green doesn’t mean that the provider will behave as the consumer expects in every situation. It just means that the consumer and provider interfaces syntactically match. But this is also a big step forward compared to the current state of many development systems. Often it is still standard here that at the beginning of an end-to-end test phase a long period of time is needed to get the test stage into a stable state in which all interfaces fit together. This goal can be achieved automatically by consumer-driven contract testing if the deployment pipelines are set up accordingly.
Matching versions on the stages
Using consumer-driven contracts to find APIs that are no longer used or using them as an exchange format to specify new interfaces or interface changes are just one useful use case.
Consumer-driven contracts only really come into their own when they ensure that only services whose interfaces match are deployed at each stage. This can be done by storing the contracts centrally (e.g., in a Pact Broker) and, in conjunction with this, by incorporating consumer-driven contract testing into the deployment pipeline.
The idea is that a consumer stores all its contracts in a central location before deployment, then triggers its providers, which verify whether the uploaded contracts are compatible with the deployed version of the provider, and then inform the consumer about the result. If the result of each provider is positive, the consumer can deploy. After successful deployment, the consumer marks its contract to indicate that the matching client for the contract is deployed on the corresponding stage. This is useful in case the provider wants to deploy a new version on the stage. He then only needs to test his new version against all the contracts of the clients (consumers) that are already deployed on the corresponding stage. Although this dependency exists between the tests, the individual build jobs can be decoupled by using WebHooks.
Another advantage of consumer-driven contract testing comes directly from storing the contracts in a central location (e.g., the Pact Broker). This is precisely the place where all the information about which service calls which, i.e. about the service dependencies, is located. The Pact Broker can even display them directly in graphical form (see here) and is thus a tool for keeping these dependencies under control.
Lack of a unified format
Currently, there are two frameworks that support consumer-driven contract testing. One is the Pact Framework – in fact, it is a collection of frameworks that enables the creation of consumer-driven contract tests in a variety of programming languages. The second framework is Spring Cloud Contract, which provides tool support for creating consumer-driven contract tests in the Spring environment.
Unfortunately, however, the two are not only two providers for the tooling of consumer-driven contracts, but also two options for formulating the contracts themselves. The two frameworks use different formats for consumer contracts by default, unfortunately.
In a polyglot microservices landscape, this is a problem. It may happen that a consumer provides the contract in Pact format, but the provider wants to process the contract in Spring Cloud Contract format.
The consequence is that the contract format must become part of the macro architecture of the microservices, i.e., a format must be defined for all services.
Fortunately, however, Spring Cloud Contract offers the possibility of importing and exporting Pact Contracts. So if I go the other way around and define for my macro architecture (i.e. for all services) that the Pact format should be used, then individual participants can still use Spring Cloud Contract in combination with the corresponding import or export. You then only have to be careful with the parts where the two formats do not contain the same information.
Just the section about matching versions on stages shows the complexity of using consumer-driven contracts. At the same time, they are only useful if the coverage of APIs is complete. Otherwise, they would not be useful for determining whether an API is still in use, for example. It could also be that the client simply forgot to write a contract for it.
Furthermore, the approach only works for internal APIs, of course. For public or semi-public APIs, the consumers are usually not even known. And even if they are, it will be difficult to ensure, that they provide contracts. In such cases, it is more advisable to focus on the provider contract. This can be defined e.g. by JSON Schema. Then, tools such as REST Assured can be used to test whether the implementation actually adheres to the contract without a consumer being involved.
To address the problem of provider state and behavior, Nicole Rauch proposes to implement a mock server instead of consumer contracts, which provides the so-called functional essence, i.e., the functional core of the provider without ancillary aspects such as persistence, security, etc., and is therefore easy to implement. Of course, it must behave exactly like the real provider and can then be used to test the consumer.
Consumer-driven contract testing offers a way to test the dependencies of services and – via integration into the CI/CD pipeline – to ensure that only suitable versions are deployed on a specific stage. In this way, it can be guaranteed that APIs are only further developed in a downward-compatible manner. The safe removal of APIs that are no longer used and the visualization of the dependencies between the services are virtually a free gift.
However, these advantages are bought by a complex development and build setup. In addition, successful contract tests can offer deceptive security. They say no more than that the interfaces fit syntactically; they do not offer a substitute for functional tests. Also, defining and managing the possible test states of a provider can sometimes become very complex and still require communication between teams. If one is willing to take on this complexity, however, consumer-driven contract tests offer a solid basis for a functioning interaction of the services in a microservices landscape.