Designing and validating microservices boundaries
In previous posts I’ve talked about a problem which I’ve seen many times – modern coupling. In this post I will give you a list of tools for discovering proper service boundaries that will reduce that coupling to its minimum. Since the topic of this post include the word “microservices” this list it’s applicable for any architecture style. No matter how you’re deploying your application (modular monolith, microservices), you can still use it to find correct boundaries to avoid coupling in your domain as much as is possible. However, microservices are quite popular topic nowadays, so I’ve used it as a little clickbait :).
What this post is not about
This post would not cover a process of discovering your domain itself. This is another huge topic to discuss. To design your services boundaries you will need excellent knowledge about your domain. This is not the topic for this post, so I will assume that you have it. If you don’t, at first, please try to understand your business that you are working in before move forward. To achieve this I will truly recommend a tool called event storming. There is a great repository of materials on this topic here: https://github.com/mariuszgil/awesome-eventstorming.
Why is it important to design correct services boundaries? I’ve explained it in previous post, but long story short to reduce coupling to its minimum and achieve services autonomy which give us freedom to evolve them separately without spreading the logic through many services and possibly breaking something on the way. The Goal is to not end up with a distributed big ball of mud which is terrible to maintain.
What is service?
The term service is so overloaded in our industry, so its worth to describe my view on it in this post. Service will be considered as a logical unit that has technical authority on specific business capability. It doesn’t really matter how we deploy them. They can be deployed as a single application. In monolithic app, service it’s often called a module (modular monolith). If we want to distribute our system for whatever reason, then service can exists in multiple physical locations. What is important that each service has clear defined boundaries and should be treated as black box.
Service in 4+1 architecture view will be placed in logical view.
Tools for validating service boundaries
To check is our service boundaries are correct we can examine them with the following approaches.
Business doesn’t know what they want, but they are pretty sure what they don’t want. Ask your stakeholders about anti requirements. What I mean by that? Ask some “dumb” questions that can tell you that any relationship between some data exists. For example:
When we change product description is this affect its price? If the answer is no, then you found a business rule that tell you that the description of a product doesn’t have to be in the same service as price. Ask questions that trying to cross your services boundaries.
A Piece of data should be modified inside one service. There should be only one single source of truth of data. That value should be referenced by the other service by some kind of identifier. Of course, specially in distributed system there will be scenarios of breaking that rule for performance reasons like distributed searching (search engines), but it should be avoided as much as possible.
When you see a situation where within one service you have to always ask questions like: Is this client premium one? Is a client has free subscription? And then process the request differently, it’s a sign that service could maybe be separated.
Lack of autonomy
Service autonomy gives us freedom to evolve independently without involving other service to fulfill part of some process. This is quite hard to achieve, but the more service is autonomous the less coupling exists between them. If you see that one service needs data from another services, then be careful, probably you are crossing boundaries. Achieving autonomy is hard and there will be some scenarios that you will need data from other services to fulfill some business requirement. However, most of the time it is possible to maintain strong autonomy using some techniques that I will show you in this post.
Problems with consistency
If something have to be consistent immediately, it should be in the same service or should be designed with a single source of truth first approach.
At the time when we get an answer from the warehouse, an item could become unavailable.
Then we are confirming the order but cannot send the item to the client.
If your events sharing a lot of information, then it’s a sign of crossed boundary and lack of autonomy. To prevent that take a look on “create upfront” approach below.
Number of services
If you have a feeling that your modeled business process could be shortened, probably you are right. The Word “micro” in microservices makes us think that services should be small. This is often the reason for unnecessary division of processes which increasing network communication between your services. Use single responsibility principle also on your service level.
Can you explain clearly, for what, service is responsible for? Does it have technical authority on specific business capability? If you cannot answer that question in simple words, then this could be a hint, that responsibility of that service is fuzzy and its boundaries are overlapping each other.
Patterns and approaches to keep good boundaries
After we validated our boundaries and found possible issues, we can use the following list to design them better.
Boundaries are not fixed lines, so be prepared
We have to keep in mind that, boundaries will change together with business requirements. Good practice is to design your system in a way that single database transaction cover the fewest objects as possible, ideally one. This rule is taken from domain-driven design, which tell that in a single transaction there should be only one aggregate updated. Even when you are not using DDD in a specific scenario, keep that rule in mind. Modifying a small object in the one transaction boundary, will give you a flexibility to move them to other service easier, when requirements have changed.
Defer naming things
Naming things prematurely could guide you to some wrong assumptions and directions. If you at early-stage, you name service a Customer service, and someone from the business would tell you that the customer has a status, then probably you will put it in that service. But Customer can mean different things in different contexts. Defer naming things until it’s necessary to avoid putting data in the wrong place.
Reverse some database relationships
For all those years we’re involved in designing things in relations and big entities. Since big tables with foreign keys to half of the database worked fine for some time, when the system grows this coupling on database level causes many problems. If this happens, refactoring the code seems easy when comparing to refactoring the database. When you are dividing your schemas, you can reverse some relationship to make them more flexible.
Question you can ask here: is order can exist without shipment? Probably, yes. On the other hand, shipment probably makes no sense without order. If you see that kind of dependencies, it’s better to design it in a way that things that cannot exist independently should have dependency on its parent. This will give us more flexibility to change.
Creating small events for communication between services is easy in theory, but in real life we end up with sharing a lot of information in events or calling services HTTP APIs to gather necessary information.
But there is another way. Instead of creating resources after everything is confirmed on UI level using fat request, we can create everything upfront before it’s confirmed.
With this kind of reversed workflow, we are keeping services autonomous, avoiding coupling and communicate only by events that contains identifiers.
This will possibly leave us with some “trash data”, when order will not be confirmed, but clearing this shouldn’t be a problem. It sometimes could be even useful. Information about not completed processes can be used with business analytics. More about this approach can be seen here.
Source of truth first
If you have a requirement where you need data from another service to proceed, instead of calling this service directly or duplicate that data in your service, start that process in the source of truth first and then move forward. This will reduce coupling between services and again give you more autonomy. It also will save you from stale data and consistency issues.
Instead of calling a warehouse from orders to verify that item is still available, we can reverse the process and go through the source of truth first. Then orders service not have to even worry about item availability since this is not its responsibility.
Try to force yourself to send thin events with non-volatile data only
Sharing data is easy but has terrible consequences when is done carelessly (distributed monolith). Of course, it won’t be possible all the times, but should be dominant approach.
Thinking in designing communication by thin events with non-volatile data only will force you to decide which service its responsible for each piece of data. A Good example of non-volatile data are identifiers.
View model composition
When you split data into services, you will have to somehow show it to your users. You can do it in many ways, but somehow you have to collect data together. A Common approach to achieve this is coordination pattern which is implemented in some API gateway. Then the coordinator coordinates remote calls for each service and then creates a view model combined from data from multiple services.
But, the approach which give us more flexibility is a loosely coupled view model composition. Instead of using a coordinator pattern, each service can register itself as requests interceptor. Then multiple services can concurrently append its data to a shared view model. An Example of this approach can be seen here. When you want to add new service to handle part of your view model, all you have to do to implement interface and composite gateway will invoke your implementation when request come. This will not require any coordination.
Integrations will happen, but we can handle it better
Sending emails with customer orders, generating invoices, price calculation based on data from many services. Most of them can be designed by view model composition, but some scenarios will probably require data from few places. For example, when you’re integrating with external systems, that you not have control of. You will face similar issues in your apps. You can’t avoid it. But we can avoid all fallacies of distributed computing on your side.
Instead of orchestrating remote calls to get data from each service, we can deploy some specific services components into a service that needs that data. This gives us big advantage of communication in process rather than by network. We can use old known dependency inversion to design it. Code example can be found here.
This of course creates necessary coupling, but we have to be careful. If it’s possible, try not to make a decision based on data that other system provide. All business logic required to produce data should stay in service which has that data. And of course not store that data in other service.
Keeping good service boundaries is worthy. It’s giving us flexibility to develop features and change faster. Otherwise, we can end up with a distributed monolith which will be only getting worse. Of course, like everything in IT, above patterns, heuristics and approaches should not be treated as strict guide which we have to follow no matter what, but we have to be aware of them when designing your systems.