In message-based systems, the messages are part of the contract, which defines how services communicate with each other.
Evolving contracts over time is not an easy task and the appropriate strategy should be defined individually for each system. Generally the problem can't be resolved at the infrastructure level; therefore NServiceBus users must analyze their individual systems, consider how they are expected to evolve, and define the strategy which will make the most sense in their particular circumstances.
This article presents basic guidelines for choosing contracts evolution strategy, avoiding common mistakes, and ensuring that contracts will be easy to evolve over time.
Handling breaking changes in contracts
There are a number of approaches to handling breaking changes in contracts. One of the easiest methods is creating an entirely new contract type by copying the original and then adding a version number to the original name. Once the endpoint is upgraded to the new contracts assembly, create a new message handler for the new message. The message handler for the original message can still process the original message, both for in-flight messages and also for messages generated by endpoints that have not been upgraded yet to the new message contract.
For complex systems, especially when there are significant changes in a message type, such as adding or removing properties, or changing the property type, a step-by-step approach to evolving message contracts is recommended.
Adding data to a contract
Depending on the complexity of the change, the number of senders and receivers for the contract as well as how the change might propagate through the system, a different approach may be desirable. It's important to consider where the added data is required for a message to be successfully processed.
Considering a scenario in which the data that is added to the contract is not required to successfully process a message:
- Update the contract and release a new version.
- Instead of relying on .NET to set the default value for int Age = 1, it's better to use nullable types and represent missing values as null. This allows for message handlers to reliably verify if the data is available using the ´HasValue´-property.
- Update senders to use the new contract version.
- Update receivers to handle the new contract version.
- This can be done gradually, endpoint by endpoint. Endpoints that are not upgraded and reference the previous contracts assembly will be unaware of the newly added data as it will be ignored during deserialization.
For a more complex change in which receivers require the data in order to successfully process a message, a more gradual upgrade is recommended.
For the contracts assembly:
- Create a new contract that either:
- is a copy of the original contract, adding the additional properties that are needed
- inherits the previous contract instead of adjusting the same type
- Release a new version of the contract's assembly
For senders and publishers:
- Update senders and publishers to target the new contract's assembly
- Update senders and publishers to use the new message contract when sending/publishing messages
This may be done gradually, deploying endpoint by endpoint as needed. Endpoints that are not upgraded and reference the previous contracts assembly will be unaware of the newly added data as it will be ignored during deserialization.
- Update receivers to target the new contracts assembly
- Add an additional message handler that can handle new message contract
- Adjust the handler that handled the previous contract version:
- Adjust message processing by assigning a default value to the missing properties. It's necessary to carefully examine what the default values for the added properties will be. In particular, consider how clients might interpret the default value and provide appropriate guidelines for them.
- When the values for the new properties are required and the data is available in an accessible storage, it can be retrieved as part of the message processing
- When the new properties are required to correctly process the message and are stored by another service:
- Convert the original message handler to initiate a saga
- When the message is received, and the handler identifies that a part of the data is missing, send a dedicated message to the relevant endpoint to retrieve the missing information. If needed, keep track of the data from the original message by storing all relevant information in the saga
- When the data is retrieved, a new message containing all required information can be sent. This message will be handled by the same handler that processes the new message contract type, deferring the actual processing to a single handler.
When all senders and receivers are updated and in-flight messages in the old format have been handled, the previous message contract can be marked as obsolete. By decorating the previous contract type with the
Obsolete attribute, the changes become visible for receivers when they upgrade to the new version. In the next major version of the assembly, the obsolete types may be removed.
Removing data from a contract
When the need rises to remove data from a contract, the easiest way to implement this change is to start from the receiver side.
Adjust all receivers to not rely on the data that needs to be removed by:
- Retrieving the data from somewhere else
- Removing the data completely
In the first case, the same way of working can be applied as when adding data to a contract:
- Convert the original message handler to initiate a saga
- When the message is received, and the handler identifies that the required data is not part of the incoming message, send a dedicated message to the relevant endpoint to retrieve the missing information. If needed, keep track of the data from the original message by storing all relevant information in the saga
- When the data is retrieved, the original message handler logic may be invoked to process the data
When all receivers have been updated to allow for processing with less data, the contracts can be adjusted.
If the data that needs to be removed wasn't crucial to start with, it could be marked as obsolete, or removed from the message contract. When choosing this option, ensure that receivers can successfully process the message without the removed properties.
Transitioning serialization formats
Another approach to handling breaking changes is transitioning serialization formats. Step-by-step guidance is provided in the transitioning serialization formats sample.