Circular dependencies between Microservices
Leonardo Mangano
Posted on May 17, 2022
Circular dependencies are a bad practice in Microservices architecture that usually goes unnoticed. Yet avoiding them is crucial to reduce complexity in this architectural pattern.
When working in monolithic systems, there's widespread agreement that these dependencies are evil. On microservices, though, the story is completely different. In this pattern, we treat services as independent modules that communicate almost without restriction. This approach is very prone to creating circular dependencies.
Having circular dependencies between microservices will result in services that are hardly maintainable. You'll not be able to think on a single service at a time as they'll be highly coupled.
Let's explore some practices that we want to avoid to get rid of circular dependencies in our Microservices architecture.
Leaking domain knowledge
A very imperceptible way of creating circular dependencies between services is sending requests with IDs, value objects, or any other kind of knowledge that belongs to the client service domain.
Example
Suppose we have these services:
- ProductService
- InvoiceService
Inside our ProductService we will surely have a Product with each entity having a ProductId. In this example ProductService will be sending a request to InvoiceService to generate an invoice that could be something like this:
POST /invoice
{ productId: 3, quantity: 1, ... }
In this situation ProductService has to know that some invoice service exists to send the request. However, our InvoiceService will also be aware of the existence of some Product entity that is in the domain of the caller. A good way to verify if we are creating a circular dependency is thinking about the InvoiceService as a separated entity. If we completely remove the ProductService, will this request still make sense as it is? The obvious answer will be 'No'.
To solve this, first we have to decide which service will depend on the other, say Product on Invoice. After we decide that InvoiceService will not depend on ProductService, we need to think about the former in isolation. Do we really need to receive a ProductId to validate the existence of the product, get its unit price and description, and then generate the invoice? Or do we just need a ProductCode to display in the invoice? Most of the time the answer will be that receiving the ProductCode, UnitPrice and Description in the request is good enough. Trusting the information received from services behind our control is crucial to avoid unnecessary validations.
POST /invoice
{
productCode: 'A123',
description: 'Nice product',
unitPrice: 99.9,
quantity: 1,
...
}
This is just a simple example but keep in mind that there are a lot of ways to leak domain knowledge to other services without us noticing.
Choreography
This communication pattern seems to be very common in Event-Driven systems. In it, services emit events into a common Event Bus so others can react to them to complete a complex distributed process.
Example
-
UserService emits a
UserRegisteredEvent
-
CreditService receives that
UserRegisteredEvent
and validates the credit of that person -
CreditService emits a
CreditValidatedEvent
-
UserService receives that
CreditValidatedEvent
and finishes the user creation process
Some people may think that, as we are using events to communicate between services, we are completelly decoupling them. That is only partially true, because the only kind of coupling we are removing is the temporal coupling. On the other hand, we are now creating a circular dependency between them because events, as requests, are part of the API of our services.
One possible solution to this, without removing the asynchronous nature of our system, is using commands and callbacks for responses. In our example, UserService attempts to validate the credit of the person to finish the bigger process of registering a user. Modeling this with events is a mistake because this is actually a command: something to which we expect some response to continue with our work.
To overcome this, we'll need to change a few things. First, our CreditService will need to have an input topic/queue to receive commands. Apart from that, instead of emiting a UserRegisteredEvent (we can still do it for other purposes) we are going to explicitly send a ValidateCreditCommand into that topic. In the header of that command we'll specify a callback topic where we want to receive the response. In this case, that'll be the input topic of our UserService.
Now, if we look at our CreditService in isolation, we will notice that it doesn't know anything about our UserService anymore. With this simple tweak, we have removed the circular dependency.
Conclusion
Having to think about or change a lot of services for every business requirement is tiresome and a good symptom of circular dependencies in our Microservices architecture. There are plenty of examples that I may be missing, but the main point is that they are something that every developer wants to avoid. As an advice, we also need to pay atention when implementing Microservices to avoid them at all costs. This is especially true when implementing microservices. Avoiding them will allow us to think in one service at a time and reduce the cognitive load in our brain.
Posted on May 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.