Microservices Layered Architecture: because not all events are created equal
In this post, we’ll discuss the coupling between services and dependency management. Take, for example, the two diagrams below: which design looks more maintainable?
Even without knowing what the system does and what the arrows mean (HTTP communication, Messaging, etc…), all things equal, the design on the right seems more maintainable than the one on the left. When designing a microservices application, it’s critical to get the dependencies between the different microservices right. In most systems, what makes complexity skyrocket is not the number of components but the number of relations between the components.
Using asynchronous events is a great way to increase the availability and, very likely, the performance of the whole application. However, does that mean that using asynchronous events reduces logical coupling? The answer is no. As long as the two services are communicating with one another, it doesn’t matter if it’s blocking using REST or non-blocking using events, there is logical coupling. Every new dependency we add makes our application harder to understand, harder to maintain, and harder to change.
Managing dependencies between components is nothing new in Software Design. The chances are that within each of your services you are using a variation of the classic Layered Architecture.
Note how the dependencies go from top to bottom. We recognize that our business or domain logic should not depend on the way our service communicates with the external world. We also assume that our service will need to support multiple communication protocols (e.g., REST, Kafka, CLI), and maybe even multiple versions of the same API (so as not to break backward compatibility). For all those reasons and many others, we don’t allow our Domain components to depend on our Presentation components.
We should apply similar design considerations when designing our systems. Not all services are created equal. Some services will be core to our business domain; other services will act like workflow managers, orchestrating between multiple services to perform a given flow; other services will act as proxies and aggregators; other services will provide cross-cutting functionality like sending emails or doing authentication. Let’s consider the example below.
Consider a simple e-commerce application with the following services:
- Orders: responsible for orders lifecycle
- Products: responsible for products lifecycle
- Email: provides emailing functionality that other services can use
- Import: allows bulk import of products and orders into the system
Once we start thinking about the nature of each service, we might see that:
- Both Orders and Products services are core domain services.
- The Email service provides cross-cutting functionality, is not part of the core domain, and should not be affected by changes in any other service. In a big enterprise, this might be a platform service that is shared and re-used across multiple projects.
- The Import service is acting like a workflow manager. It depends on the core domain services. In the future, there might be other ways of entering data into the system.
The above considerations are important because they help us understand how services might change in the future. The main reason we spend time designing a system is that we want it to be able to accommodate change comfortably. Having an idea of why and at what rate our many services might change will help us build a better system.
We expect our core services to be pretty stable during the lifetime of our system. Even if they change, as they are part of the core domain of the application, we don’t expect them to be deprecated or removed in the future. Services that provide cross-cutting functionality, which are not related to the core domain of the application, shouldn’t have a reason to change often. We also don’t want them to depend on other services that don’t provide cross-cutting functionality. Workflow managers might change frequently, or new workflow managers might be added or removed during the lifetime of the application.
Based on the above observations, we would organize our services as follows:
So, what does that diagram tell us? If we are talking about REST communication, we will avoid having the Orders and Products Services calling the Import Service directly. The other way around would be acceptable: the Import Service can call the core domain services.
The tricky part comes when we start talking about events. Can the Products and Orders Services consume events from the Import Service? That depends on which kind of event. Just like we have the core and non-core services, we also have the core and non-core events. For example, an Order Created event would be a core event. On the other hand, an Import Started event would not be a core event. The import service doesn’t belong to our core business domain, and therefore we don’t need any of our core services to know anything about import.
The rule of thumb should be for core services to consume only core events. Doing this will allow us to introduce new ways of importing data to the system without affecting any of the core services. Note that in any non-trivial enterprise system, most of the complexity will be in the core domain services. The more stable they are, the easier to maintain the system will become.
Whatever your approach for designing your system is, you’ll need to have a strategy for limiting complexity and coupling between services.