I have been thinking a lot about coupling lately. To couple means to link or tie, as one thing to another; to connect or fasten together; to join. Michael Nygard distinguishes between several different kinds of coupling that can occur in software. What they all have in common is that changes in one place, concept, or aspect propagate through the system. This amplifies the cost of making changes and increases the risk of introducing bugs.
Suppose two networked services are operationally coupled, for example because one of them makes an API call to the other one. Then changes in the availability and performance of the server flow back to the client, impacting its own availability and performance. There are several well-known solutions that make different tradeoffs in combating this (queues, publish-subscribe, and graceful degradation, to name a few).
Suppose a new required parameter is added to a widely used API. All callers have to be updated to pass in the right argument. Michael refers to this as development coupling; I would have called it schema coupling instead. This can be avoided by choosing to avoid breaking changes, e.g. through optional parameters, function overloads, or versioning.
Suppose our software publishes metrics to an observability service. A business decision is made to adopt an alternative provider as a cost saving measure. If the responsibility of publishing metrics is duplicated in many places, the cost of this change will be high. Functional coupling like this can often be avoided by introducing a layer into the system, perhaps in the form of another service or shared libary.
The most pernicious kind of coupling is semantic coupling. It occurs when concepts are shared between disparate components. In other words, they "know" too much or embed assumptions about other parts of the system. There is no simple fix when these assumptions break; often, large parts of the system have to be rearchitected to accommodate the desired change, which can be very costly indeed.
Measures we take to avoid coupling may bring their own tradeoffs and costs, and any useful system will inevitably have some coupling between its components. This does not imply that there is nothing to be gained from striving to avoid or reduce the amount of coupling in our systems. We would do well to take a fresh look at the world around us to seek out examples of systems that have successfully addressed this challenge and learn from them.
One such example is React.js, which needs no introduction (but do check out the documentary if you haven't yet). Despite being a frontend library, React can teach us a thing or two about how to structure our backend systems so as to reduce coupling. Fortunately for us, its authors have published a set of design principles. First and foremost:
Composition
The key feature of React is composition of components. Components written by different people should work well together. It is important to us that you can add functionality to a component without causing rippling changes throughout the codebase.
The way React achieves this is through the idea of one-way data flow. Each React component may render zero or more children. Data flows only in one direction – from parent to child. This poses a dilemma: what if some piece of information that is only known by a deeply nested component is needed somewhere above it in the hierarchy?
Unlike, say, in an object-oriented programming language, it's not possible to pass around references to components and have them mutate one another's state. And for a good reason! Doing so would tightly couple these components together, making their emergent behaviour difficult to reason about. They would also become impossible to reuse in new contexts. In the words of the late Joe Armstrong:
The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
Instead, the canonical solution is to pass functions down to components, which they can invoke to notify their parent that a particular event has occurred. In this way, each component remains unaware of the context within which it is being rendered; it need not "know" anything about what will happen in response to the event. In other words, components are loosely coupled to their parents but tightly coupled to their children (unless one uses dependency injection, which involves passing in those children as props; we won't delve into this pattern today).
This gives us the power to take any subtree of a React application and render it in a different context. We can render a particular subtree in a unit test to verify its behaviour and guard against regressions. We can re-use it in a different part of the application.
What would it look like to apply the principles of composition and one-way data flow to our backend architectures?
Over the past decade, microservices seem to have become the dominant architectural pattern that software-heavy companies use to structure their systems. At their best, microservices enable large organisations to maintain agility by enabling teams to move autonomously without low coordination overhead. At their worst, they become distributed yet tightly coupled Rube Goldberg machines, causing work to grind to a halt as teams struggle to untangle the mess.
Let's try to describe the desired outcome. Hearkening back to the words of the React authors quoted above, we want the ability to add functionality to a component without causing rippling changes. How might we achieve this? Let's try to apply the idea of one-way data flow:
Each microservice may make API calls to zero or more dependencies. Data flows only in one direction: from client to server. This poses a dilemma: what if some piece of information that is only known by a deeply nested dependency is needed somewhere earlier in the call chain? It would be tempting to introduce an API call from the deeply nested service to the one that needs the data, but this would violate the principle and lead to tight coupling, thus giving up all the benefits of composition.
Recall that React solves this problem by allowing child components to notify their parents of novelty by invoking a callback function. We can use the same technique for services that share the same memory and execution context (i.e. they are part of the same application), but in modern microservices architectures, our services are deployed separately and interact only over the network. However, we can use a different mechanism to achieve the same effect: events.
The key is to ensure that any service publishing an event does not know or care about the services that consume it. This brings similar benefits to those we discussed in the React context:
It gives us the power to take any subtree of a microservices architecture's call tree and re-use it in a different context. We can spin up a particular subtree in a system test to verify its behaviour and guard against regressions. We can re-use it in a different part of the application. But most importantly, services can evolve without forcing changes in neighbouring services. In fact, events can be consumed by any service in the system (including ones that did not exist at the time the producing service was created). This, in turn, allows us to add new functionality by introducing new services rather than by modifying existing ones.
At least that's the dream. The real world has a habit of being a bit more complex than we would like, so even if we follow the principle of composition, we may find that we have managed to introduce other forms of coupling. Just like real React applications tend to have flaws and challenges, the perfect microservices architecture will remain elusive. Nevertheless, we stand to gain a lot from being more deliberate about the coupling we choose to introduce into our systems.