Consumer-driven Coupling: Patterns and Anti-patterns
Organisations maximising the advantages provided by microservices tend to be organisations which view microservices not just as a technical tool, but a sociotechnical tool.The way software systems are shaped determines the communication paths and the coupling between development teams.
As microservice systems grow, the risks of sociotechnical coupling increase significantly. Layers start to emerge and as a result, shipping new customer-facing features require changes that cut through multiple layers of the architecture. Subsequently, the problem of coordinating multiple teams arises, each with their own backlog and performance goals to achieve.
Being aware of the patterns inherent to layered sociotechnical architectures can save you a lot of pain and politics in the long-term.
In this article, we will look at coupling which arises due to needs of consumers (services that depend on others or ‘consume’ them) and the teams that build them. We’ll see patterns to avoid and mitigate the coupling, and spot future hazards before they become major headaches.
Subservient Contexts (Anti-pattern)
The more you look at the relationships between software systems and the organisations that build them, the more fascinated you become.
Very often, there will be dominant teams in an organisation. Perhaps led by strong managers, they will fight vehemently for their work to be prioritised and software systems to be designed to their needs. As a consequence, we also have subservient teams — teams with less aggression or power having to accept the choices of other teams.
This organisational pattern can be mirrored in the software architecture, emphasising the sociotechnical nature of systems. The result is subservient contexts (bounded contexts aka microservices).
Subservient contexts are contexts which are designed and built to the requirements of their consumers, either by force or an attempt to try and be helpful. While it is nice to go out of your way to please others, the resulting compromises to the software architecture can lead to dangerous sociotechnical side-effects.
Microservices should be able to evolve independently. This is loose coupling, a key benefit of chopping up an architecture into smaller pieces. One way to negate this benefit, however, is for microservices to have custom logic (typically exposed as API endpoint) specifically for use by a single consumer.
Consumer-specific endpoints mean that any new clients must risk coupling themselves to an endpoint designed for another consumer which doesn’t suit their needs and forces them to compromise their design.
The obvious remedy is to add another consumer-specific endpoint for the new consumer. But as the number of consumer-specific endpoints grows, the problems for the subservient context team start to become noticeable. Their code is more complex having to support multiple use-cases, and the team becomes a bottleneck — multiple consumer teams will all be pushing them to make changes to their consumer-specific endpoints.
When a new feature is added to one endpoint, it may also have to be added to the others. The costs of a piece of work can double or triple depending on how many times it has to be duplicated, and the maintenance burden will become equally costly.
Consumer-specific Endpoints in the Government
When working for the UK government on a digital tax system, I remember an intriguing case of creating consumer-specific endpoints. We were developing a brand new service, the frontend website and the backend business capability microservices.
Initially one microservice had a single endpoint which validated a complete tax submission, containing around 20 pieces of user-supplied information.
On the frontend, the user researchers and design team were experimenting how to best lay the questions out for the simplest user experience. Each week the page layout would change, and the order in which questions were asked changed, and which questions were shown on each page changed.
Significantly, users needed partial validation — when the user filled in a page, they needed immediate feedback to know if subset of questions they had answered were valid answers. The rules were moderately intricate. The value of certain fields would dictate what was valid in others and so on.
New microservice APIs could have been created to provide the partial validation, but those APIs would be coupled to the structure of the website. We anticipated future users of the APIs who may want to lay their pages out differently, so the validation endpoints would not have worked for them.
The alternative to consumer-specific endpoints was to duplicate the validation rules in the frontend websites, or potentially provide some client library. It was too complex to represent the rules in JSON schema (we tried).
Duplicating the rules in the frontend would have avoided the consumer-specific endpoints but introduced the risk of another problem — anemic domains and envious consumers.
Anemic Domains and Envious Consumer (Anti-patterns)
You may be familiar with the Anemic Domain Model anti-pattern from Domain-Driven Design and the Feature Envy code smell. These anti-patterns involve responsibilities living in the wrong place causing system design and maintenance issues. These concepts also apply to distributed architectures.
One way to avoid a dependency between microservices is for the consumer to own the responsibility — whatever rules or data are needed from the downstream service, just put them inside the consumer instead. There is no longer a need for the dependency (and so the consumer is not a consumer anymore).
In this situation the consumer is envious of features that should belong elsewhere. As a consequence, the downstream service is anemic — responsibilities it should own belong in the consumers and it is void of domain rules.
Duplicated Domain Rules in BFFs and Frontends
A common example of envious consumers is seeing the same domain rules duplicated in frontends or their BFFs.
Frontend teams don’t want to wait for domain teams to make changes, so they add business rules in their BFFs themselves. The web team, the Android team, the iOS team may all write the same validation rules to avoid depending on APIs.
At first it may seem harmless, even beneficial, enabling the three frontend teams to get new features out the door without being blocked by the API teams. When those rules need to change in future, however, the drawbacks can be dramatic. The app teams will all need to make the same changes to the rules. Apart from duplicated effort, the changes may be subtly different, completely incorrect, or made at different times. Consequently, the user experience may differ across apps or bugs may arise.
Fat Facade Contexts (Anti-pattern)
At some point, we need to add abstraction in a microservice architecture. When multiple consumers are all calling multiple downstream microservices in the same order, the duplicated coordination logic is hinting at an abstraction.
A microservice which encapsulates coordination of other microservices and provides a simplified interface is known as a facade microservice (other names are also used).
Sometimes however, due the requirements of a consumer, the multiple responsibilities which should be individual microservices become merged into a single microservice which provides the facade API. This is a fat facade because the facade doesn’t actually delegate, it contains all of the responsibilities.
By aggregating multiple responsibilities into a single microservice, we start losing the benefits of modularity. Sociotechnical coupling increases and the ability to evolve different parts of the system in parallel reduces.
The solution is to model the domain as bounded contexts, and model the coordination logic as a separate facade context. The additional service-to-service integration is definitely an overhead, but that’s the cost of microservices.
Fat Facades in IoT Fleet Management
A group I previously worked with were designing a service to remotely monitor a fleet of devices. A user could see on their mobile phone screen the current and previous location of the device, the speed and direction it was moving, the health of the components on the device, and more.
One team were building the mobile app and other teams were building new backend APIs replacing an old monolith. The new APIs would initially be consumed just by the fleet management app, and then after they would be used by many consumers within and outside the company.
At first, the API developers looked at all of the data in the monolith’s database and decided that because all the data was in the same database they could provide a single API endpoint which contained all the data the app needed so it only had to make a single call when a page was loaded or updated.
This, however, would have resulted in a fat facade — a single monolithic microservice which a facade API but a bloated implementation actually containing multiple bounded contexts which it should have delegated to.
Later, the team realised that future consumers of their APIs may want just location, or just component state, or just device information. They knew that the fat facade would have been heavy consumer coupling and caused them severe problems, so they discarded the design.
BFFs vs Facade Microservices
Deciding where coordination logic lives is a really tricky aspect of design. Usually you don’t have all the information required to make a good decision when you have to make the decision.
Coordination logic could be a genuine domain concept. If true, it will be relevant to all downstream consumers. There will be no (or little variation) depending on the consumer making the call. This logic should live in a microservice open to many consumers.
Coordination logic may also be consumer specific. Different applications may choose to display information differently on different web pages. That’s not a domain concern, it’s an app-specific design concern. This logic should live close to the frontend in a BFF.
Making the wrong design choice may mean your microservices are coupled to their consumers or your domain is anemic. Often, it won’t be until you have multiple consumers that you can ascertain which it is. This is why it is vital to spend time properly understanding the domain you are working in. Find those domain concepts and you’ll have a much clearer picture of what is and isn’t part of the domain.
Striving for Open Host Contexts
In an ideal world, we are striving for a clean separation. Each bounded context contains a cohesive set of business rules and data agnostic of specific consumers. And each frontend is dedicated to delivering a great user experience, while delegating to microservices for business rules.
One pattern originating in the Domain-Driven Design community we can strive for when designing systems is the Open Host. An Open Host is a bounded context which provides a well-designed, domain-focused API for use by many consumers and not customised to a specific consumer.
Striving for an Open Host as your default will be challenging but it will be worth it. Typically, you will need to work hard to understand your domain in order to produce general APIs which work for all consumers. However, as I highlighted with the government tax example, perfection is not always possible.
Deciding Where Responsibilities Belong
Designing microservices can feel quite easy at times. And that’s because it is at first. However we slice our system and our teams, it usually works to begin with. The cost of poor design doesn’t begin to manifest until the system starts to scale. The feedback loop on sociotechnical design choices is long.
As the number of services and teams grows, and different parts of the system need to be developed in parallel, and the number of dependencies grows, major sociotechnical problems begin to manifest. Because the whole system is built atop layers of sub-optimal design choices, it becomes monumentally difficult to stop pain and improve the design.
I recommend that you pay special attention to how consumers impact the design of your microservices. This is a key challenge where dependencies and misplaced responsibilities can be the easiest to introduce, the most costly to endure, and the most difficult to reverse.
I also recommend that you take some time to familiarise yourself with the fundamental patterns: facade contexts, open hosts, and BFFs. Perhaps more importantly, familiarise yourself with the anti-patterns: fat facades, subservient contexts, anemic domains, and consumer-coupled endpoints.