Bounded contexts or consistency contexts?
"Embrace modularity but beware of granularity"
(Mark Richards)
While using the Microservice architecture in software can buy you some agility if done right, I often see architectures where microservices bring extra complexity that actually increases the implementation time. The microservice architecture often brings incidental complexity, that is often caused by the uninspired choice of our microservice boundaries.
Bounded context should guide the choice of microservice boundaries. However, I find bounded context to be a too ambiguous concept.
Any unit of software can be seen as a bounded context, even a class. A Payment sounds like a bounded context. What about a CreditCard, can it have its own bounded context?
We don't want to create a microservice for each class, for sure. Think about this when you think to create another microservice, are you going too close to the "microservice per class" anti-pattern?
On the other end, most real life software systems are complex enough that hardly makes any of the subsystems completely bounded. Any microservice you want to extract will have many wires (like API calls) that will need to be preserved with the rest. Ideally, we should cut where those wires are few rather than many, however this is not always easy.
Joins across microservices
A simple 2-tables join that works efficiently in a single database is way more complex to be computed between 2 microservices with their own database. If both tables are reasonably big and you cannot do it in the database, you need to query both microservices by (REST?) APIs and do that join in memory.
What about the consistency of data? While you make that join through API calls, data might change in each microservice, and you might end up with inconsistent results, like missing references or duplicated values.
The in-memory join might work if you only join with small tables from a remote service, like a table of names. However, in this case, why does it have to live in a separate microservice in the first place? Maybe you only needed a Java enum that is shared?
It is also doable to make a join between a selected list o local entities (like 100) with corresponding entities from another microservice. You can even have bulk APIs that serves all 100 entities from a list of kees. You might have some inconsistencies if you don't pay attention, however it can work.
But when you have complex joins between big tables of separate microservice, you might end up with a shared database in the end. But first, you will spend a lot of time trying to make it work via API calls :)
Transactions makes it even harder
Having too many calls between microservices can bring extra latency and serialization/deserialization overhead. However, the big monster of microservices is achieving transaction semantic across microservices. For example, you need to synchronize the buying order with the payment. You cannot have one without the other.
True microservices are designed to use separate databases. This forces you to not use the well-known database transactions and rely on complex sagas that are rarely achieving all the transactionality guaranties you get from a single database (like SQL). This brings extra design complexity. This makes the development time way longer, the debugging more difficult, and the resulting system way more fragile.
We can relax a bit the purity of microservices, and share the same database when the transactionality force is too strong between those "bounded contexts" that you previously identified. However, in this case you might want to consolidate those microservices that share a single database in a modular monolith or modular microservice. The main advantage of microservices comes through independent deploy-ability. If you cannot have independent deploy-ability, chances are that the gains don't justify the complexity cost of the extra microservices.
It is often desired to relax the transactional requirements and rely on eventual consistency when possible. A technique that assures a certain level of consistency is to construct a kind of immutable versions of entities, that refer each other - similar with how GIT works. As long as you don't delete referring entities, your image of the system will be consistent - however you might not see the most recent image.
However, often enough, business needs some functionalities to be more strongly consistent. As in the example above, you cannot have a shipment without a pay, or a pay without a shipment. And you are in big trouble when you need to implement such atomicity across separate microservices, with separate databases. Of course, you can try complex sagas that are doing compensation transactions to assure the system achieve consistency - at least eventually.
The consistency context
Transaction is a strong word. We often need various levels of consistency. Usually, the lower consistency let you distribute the system and have microservices. The higher consistency level is forcing you to rely on a single database, like aggregating some of the microservices to a modular microservice or modular monolith.
Then why not designing our microservices around consistency requirements? We still need to have bounded contexts to be able to name things. However, taking consistency into account prevents you to distribute strong consistency transactions across microservices and databases - that is really costly.
You can design you microservices around what data need to be consistently seen by the outside worlds.
If you conceive to have a customer's identity without a profile - like in a progressive profile approach, then you can safely distribute the customer's identity and profile in separate microservices.
If your business needs require for strong consistency between some properties, you might want to keep those entities in a single database, and therefore you might want to have a single service responsible for that data.
It is still possible to have separate services sharing the same database. Those are not real microservices for the purists. I could live with that compromise for the right reason. For example, if I have a module often takes too much memory and brings everything down, it might worth to extract it in a separate (micro)service - to protect the other modules. However, often there are not enough reasons to separate those services at all. They can be a modules micro-service, that is also pure for whoever care.
In the case of Payment vs Order, the data payment data is distributed by design, so you will need some compensation transactions from time to time anyway. However, it is much more simple to implement logical transaction with a database transaction whenever possible. In the payment case, it might still worth to have a local database transaction about the order and that payment transaction that happens in a remote place (a bank). It might save you from many race conditions you would need to program against.
The consistency force
If something is so coupled semantically, so that requires stronger consistency guaranties, then it might deserve to be a single responsibility (micro)Service, even modular. Even if a subsystem seems like a bounded context linguistically, it might not be a separate concern if it is bounded logically by transactions with some other concepts.
We should not artificially break functionalities that are logically cohesive in distributed microservices. Be like a good surgeon, do not section important blood vessels, or transactions for this case.
Splitting transactions across microservices only makes our life hard, and usually does not provide the expected benefits promised by the Microservice architecture.
We can still relax consistency requirements to achieve a highly distributed system with high availability. However, this is not trivial and requires a lot of compromises on business requirements. You can check my other articles to gain some insight on this approach:
Data Consistency in Distributed system - CAP, PACELC and more
Comments
Post a Comment
Comments?