Why do we lose focus the bigger things get?
When we write code we are dealing with smaller pieces of code. We obsess over atomic functions. The fact is we are Interested in the Contract for a function in the form of parameters, but when we scale this up to services, we forget all about it.
Large services are functions that mimic the inputs and outputs of smaller functions and scale up fractally.
For microservices to operate at scale we need to adhere to its contract. That is, know what it expects as an input and what we expect as an output.
It might seem like you don't need them early in the design of services. They clip together neatly, and the freedom and velocity might make is giddy enough to think we can do without it, but we cannot. We need a registry of schemas to match against to know out services can talk to one another.
I think that as software scales out and as we gain more complexity, we find ourselves unable to picture this scale and misunderstand the nature of contracts between services.
Let's start with a function in a type-sensitive language like Typescript.
We define interfaces or classes that allow us to specify the shape of input into a function, and then define one that represents the return object.
This means that when we use this function in our code, providing a misshapen input will give an error and a message to help you understand what properties are missing and which are incorrect.
Now this feedback can be in your IDE or at compile time, and if you heed the warnings and correct your mistakes, you can ensure that all the inputs match up as the function expects.
Let's scale this up to microservices.
When we deploy services, they must be independent of one another. We need to be able to deploy them separately to avoid coupling as much as possible.
Since our services are (usually) event driven we need a way to ensure that events they emit and events they receive are matched up, similar to using interfaces in Typescript.
Our tests (you do have some, right?) are only testing single services at once. We do not test everything together, only a service using its contracts.
We test a service that responds to and emits events by sending an event to it and asserting the event we expect in return. Normally this should happen in a reasonable timeframe and so we can wait for the event asynchronously.
A great format to store these event schemas is JSON schema. It lets us describe the shape of a data object, such as a Kafka or event bridge event as well as more traditional http shapes.
When microservices are deployed we take special care not to share code between them to avoid coupling, but one thing we can share are schemas, once again, like a shared Typescript interfaces folder. When we deploy a service, we can use the shared schemas together with our regular event testing suite to show that our schema is matching between services, that we have no field name errors for both pushed and received events.
So, as we can see between unit testing a simple function and testing an entire service with events there are a lot of similarities. We apply inputs and we expect outputs.
If the service has side effects that are making the test more complex or flaky, then revisit the design of the service with a view to refactoring for isolation.
If the service calls an external or third-party service that is out of our control, then mock it.
If it can't be tested, then we designed it wrong. Back to the drawing board.