microservices antipatterns and pitfalls - The Static Contract Pitfall

All microservices have contracts between the service consumers and the microservice. A contract usually contains a schema specifying the expected input and output data, and sometimes the name of the operation (depending on how you are implementing your service). Contracts are usually owned by the service, and can be represented through formats like XML, JSON, or even a Java or C# object. And of course, those contracts never change, right? Wrong.

The static contract pitfall occurs when you fail to version your service contracts from the very start, or even not at all. Contract versioning is absolutely critical for not only avoiding breaking changes (changing a contract and breaking all consumers using that contract), but also to maintain agility by supporting backward compatibility.

Here’s an example that illustrates how you can get into trouble by not versioning your contracts. Assume you have a microservice that is accessed by three different clients (client 1, client 2, and client 3). Client 1 would like to make a change to the service contract right away. You check with client 2 and client 3 to see if they can accommodate the change, and both clients inform you that it will take weeks to implement that change due to other things going on with those clients. Now you must inform client 1 that it will take weeks to make that change because you need to coordinate the update with clients 2 and 3. However, client 1 cannot wait weeks.

By providing versioning in your contracts, and hence providing backward compatibility, you can now be more agile in terms of client 1’s request. Agility is defined as how fast you can respond to change. If you properly versioned your contracts from the very start, you could immediately respond to client 1’s request for the contract change by simply creating a new version of the contract, say version 1.1. Clients 2 and 3 are both using version 1.0 of the contract, so now you can implement the change right away without having to wait for client 2 or client 3 to respond. In addition, you can make the change without making what is called a “breaking change.”

There are two basic techniques for contract versioning: versioning at the header level and versioning in the contract schema itself. In this chapter I will cover each of these techniques in detail, but first let’s look at an example.

Changing a Contract

To illustrate the problem with not versioning a contract I will use an example of buying a certain number of shares of Apple common stock (AAPL). The schema for this request might look something like this:

{   
    "$schema": "http://json-schema.org/draft-04/schema#",
    "properties": {
      "acct": {"type": "number"},
      "cusip": {"type": "string"},
      "shares": {"type": "number", "minimum": 100}
   },
    "required": ["acct", "cusip", "shares"]
}

In this case to buy stock you must specify the brokerage account (acct), the stock you wish to purchase in CUSIP (Committee on Uniform Security Identification Procedures) format (cusip), and finally the number of shares (shares), which must be greater than 100. All three fields are required.

The code to make a request to purchase 1000 shares of Apple stock (CUSIP 037833100) for brokerage account 12345 using REST would look like this:

POST /trade/buy
Accept: application/json
{ "acct": "12345",
  "cusip": "037833100",
  "shares": "1000" }

Now let’s say that the service changes its contract to accept a SEDOL (Stock Exchange Daily Official List) rather than a CUSIP, which is another industry standard way of identifying a particular instrument to be traded. Now the contract looks like this:

{   
    "$schema": "http://json-schema.org/draft-04/schema#",
    "properties": {
      "acct": {"type": "number"},
      "sedol": {"type": "string"},
      "shares": {"type": "number", "minimum": 100}
   },
    "required": ["acct", "sedol", "shares"]
}

This would be considered a breaking change in that the prior client code will now fail because it is still using a CUSIP. What you need to do is use versioning so that version 1 uses a CUSIP and version 2 uses a SEDOL to identify the stock being traded.

Header Versioning

The first technique for contract versioning is to put the contract version number in the header of the remote access protocol as illustrated in Figure 8-1. I like to refer to this as protocol-aware contract versioning because the information about the version of the contract you are using is contained within the header of the remote access protocol (e.g., REST, SOAP, AMQP, JMS, MSMQ, etc.).

500

Figure 8-1. Header contract versioning

When using REST you can use what is called a vendor mime type to specify the version of the contract you wish to use in the accept header of the request:

POST /trade/buy
Accept: application/vnd.svc.trade.v2+json

By using the vendor mime type (vnd) in the accept header of the URI you can specify the version number of the contract, thereby directing the service to perform processing based on that contract version number. Correspondingly, the service will need to parse the accept header to determine the version number. One example of this would be to use a regular expression to find the version as illustrated below:

def version
   request.headers
   ["Accept"][/^application/vnd.svc.trade.v(d)/, 1].to_i
end

Unfortunately that is the easy part; the hard part is coding all of the cyclomatic complexity into the service to provide conditional processing based on the contract version (e.g., if version 1 then… else if version 2 then…). For this reason, we need some sort of version-deprecation policy to control the level of cyclomatic complexity you introduce into each service.

Using messaging you will need to supply the version number in the property section of the message header. For JMS 2.0 that would look something like this:

String msg = createJSON(
  "acct","12345",
  "sedol","2046251",
  "shares","1000")};
 
jmsContext.createProducer()
.setProperty("version", 2)
.send(queue, msg);

Each messaging standard will have its own way of setting this header. The important thing to remember here is that regardless of the messaging standard, the version property is a string value that needs to match exactly with what the service is expecting, including being case-sensitive. For this reason it’s generally not a good idea to supply a default version if the version number cannot be found in the header.

Schema Versioning

Another contract-versioning technique is adding the version number to the actual schema itself. This technique is illustrated in Figure 8-2. I usually refer to this technique as protocol-agnostic contract versioning because the version identification is completely independent of the remote access protocol. Nothing needs to be specified in the headers of the remote access protocol in order to use versioning.

500

Figure 8-2. Schema-based contract versioning

By using schema-based versioning, the schema used in the previous example would look like this:

{   
    "$schema": "http://json-schema.org/draft-04/schema#",
    "properties": {
      "version": {"type": "integer"},
      "acct": {"type": "number"},
      "cusip": {"type": "string"},
      "sedol": {"type": "string"},
      "shares": {"type": "number", "minimum": 100}
   },
    "required": ["version", "acct", "shares"]
}

Notice that the the schema actually contains the version number field (version) as an integer value. Because you only have one schema now, you will need to add all of the combinations of possibilities to the schema. In the example above both the CUSIP and SEDOL are added to the schema because that is what varies between the versions.

The big advantage of this technique is that the schema (including the version) is independent of the remote access protocol. This means that the same exact schema can be used by multiple protocols. For example, the same schema can be used by REST and JMS 2.0 without any modifications to the remote access protocol headers:

POST /trade/buy
Accept: application/json
{ "version": "2",
  "acct": "12345",
  "sedol": "2046251",
  "shares": "1000" }
String msg = createJSON(
  "version","2",
  "acct","12345",
  "sedol","2046251",
  "shares","1000")};
jmsContext.createProducer().send(queue, msg);

Unfortunately this technique has a lot of disadvantages associated with it. First, you must parse the actual payload of the message to extract the version number. This precludes using things like XML appliances (e.g., DataPower) to do routing, and also might present issues when trying to parse the schema (particularly with XML). Secondly, the schemas can get quite complex, making it difficult to do automated conversions of the schema (e.g., JSON to Java object). Finally, custom validations may be required in the service to validate the schema. In the example above, the service would have to validate that either the CUSIP or SEDOL is filled in based on the version number.