The concepts of coupling and cohesion are key in designing robust software solutions, but are still not understood deeply enough. Introducing things like small deployable components – often under the name of microservices, used incorrectly – can make coupling harder to see, but also more painful. Feedback that in a monolith codebase would be immediately obvious at compile time can be deferred, because what was a method call has become a network call, and the “loose coupling” of topology by having things in a separate deployment masks a tight coupling of behavior and schema. Coupling could be simply described as:
If a change to Component A means a change to Component B is needed, they are coupled. If breaking Component A means Component B is also broken, they are coupled.
An example of coupled components are any pair of components that communicate by HTTP. If Component B makes a HTTP request to Component A, and Component A is offline, Component B had may as well also be offline. This coupling can be managed however; the easiest way here would to be acknowledge the coupling and build Component B to be robust to failures in Component A. This might be some kind of retry policy, to deal with transient outages; it might be a fallback or default if the issue isn’t transient. However, it is not true to claim that Component A or Component B are well-designed services, because they are not sufficiently independent from each other.
Another way that Component A and Component B here are coupled is by contract; if the shape of the message that Component A expects is changed, then Component B needs to be updated to match. There are ways to manage this coupling: if we own both components, the simplest way to manage the coupling is to simply co-locate the source code for both services in the same repository, and create a package containing the schema or message shapes that is referenced directly by both services. We can then use compiler support to detect instantly – on keypress in some IDEs – that the coupled components have been affected by a change. This is actually a very effective way of working with components that are coupled, and type safety can go a long way on its own. The temptation however is often to put components in separate version control repositories, which negates this risk management strategy and means something more complex is needed.
The next level up from compile time checking for managing schema dependencies is a versioned package; a package containing the schema and shapes is created, with a version number. Component B manages its dependency on Component A through managing this versioning. The easiest way to implement this is notoriously difficult to maintain, which is where a single version is supported at runtime. When the schema changes, all the components that depend on that schema will need to be simultaneously updated and redeployed. Again, it is not valid to claim that these components are independent services, because their coupling has tied their deployments together. In a subset of cases, it is possible for schema additions to not require an immediate update in other components, which could be described as a “relaxed coupling”. An example of how this can go wrong is if a new field is added, and semantically this field is mandatory in the request; there would be no compile-time error in Component B, but at runtime there would be failures because the new field is not populated.
In public-facing APIs, managing versioned dependencies like this requires a more robust, but more complex solution. It is simply not possible to mandate that all clients of a service update and use a single consistent version of a package, and so the service will need to support multiple versions. This can be handled as a routing concern, where a message gets routed to the right version of handler as an infrastructure concern. A common approach is to support a certain number of versions, with rolling deprecation of older versions. The service sometimes attaches warnings in the responses as a way to notify clients that the version they are using is due for deprecation.
More insidious than schema coupling is logical coupling, where Component B implicitly “knows” something about what Component A will do with its request. I’ve written about Commands and Events previously, and Commands are often an example of this logical coupling. If two components are logically coupled, the dependency is likely not to be detectable by static analysis or compilers, but will require some form of runtime checking, perhaps integration or end-to-end tests. The issue with such tests is that they require a fully deployed environment to run, and can be very brittle. The brittleness of the tests is an unfair complaint though; the tests often fail for the right reason, which is that a change in one component affects another component. The problem is not with the tests, it is with the system architecture; there is too much coupling between these components. Another way to look at the same problem is that neither component is cohesive enough; i.e. it has “half a job”. Quite commonly, this category of problem can be solved by tweaking the service boundary, and combining these coupled components into one.
The art in designing systems that are composed of multiple components is in recognizing which things can form viable services, and in not trying too hard to break things into components only to be bitten later when they turn out to be too tightly coupled. I’ve seen a few examples now with different projects, and have reached the conclusion that unfortunately the concepts are too abstract for many people to understand without first-hand experience; the problem with this is that gaining that first-hand experience often ends up being very expensive for the business. One of the easiest ways to mitigate these risks is to let someone else pay for that tough learning curve, and bring in people who have learnt “the hard way” on a different project, and leverage their learnings on yours!