How modular can your monolith go? Part 6 - transaction management for commands
architecting modular monolith service collaborationThis article is the sixth in a series of articles about modular monoliths. The other articles are:
- Part 1 - the basics
- Part 2 - a first look at how subdomains collaborate
- Part 3 - encapsulating a subdomain behind a facade
- Part 4 - physical design principles for faster builds
- Part 5 - decoupling domains with the Observer pattern
- Part 7 - no such thing as a modular monolith?
In previous articles in this series, I glossed over the issue of transaction management.
I simply assumed that each operation (e.g. request) was implemented as a single transaction.
For example, the system command createOrder()
, which is triggered by an HTTP POST /orders
request, would be implemented as a single transaction that creates an Order
, updates a Customer
to reserve credit, and sends an email to the Customer
.
In this article, I dig deeper into the topic of transaction management in a modular monolith. I’ll talk about transaction management when implementing system commands that update multiple domains. I’ll cover three different transaction management strategies:
- One transaction per operation - simple ACID transactions
- One transaction per domain - more complex eventually consistent transactions
- Multiple transactions per operation - carefully designed to avoid some of the complexity of eventual consistency
Let’s start by looking about the need for transactions.
System commands need to be transactional-ish
System operations need to have ACID-style semantics. Specifically, system commands must have the following characteristics:
-
Atomic - all of the updates performed by an operation need to be committed or none of them should be. Partial updates would leave the system in an inconsistent state.
-
Isolated - the outcome of executing multiple operations concurrently (e.g.
opA()
andopB()
) needs to be the same as if they were executed serially (e.g. eitheropA() -> opB()
oropB() -> opA()
).
If an operation doesn’t have these properties then it’s likely to be buggy.
For example, if createOrder()
did not create Order
and update a Customer
to reserve credit, then the database would be left in an inconsistent state.
Simplest approach: an operation = ACID database transaction
The simplest solution is to use a relational database and implement each operation as a single ACID transaction. While there are some subtle issues around transaction isolation levels (real world databases don’t implement textbook ACID transactions), this approach is generally simple and easy to implement. The database will ensure that the operations are atomic and isolated (as well as consistent and durable).
ACID transactions = key benefit of the monolithic architecture
The ability to use ACID transactions is one of the key benefits of monolithic architecture.
For example, you can simply use the @Transactional
annotation in a Spring Boot application to make every operation transactional.
Lack of ACID for distributed operations = key drawback of microservices
Conversely, one of the key drawbacks of microservice architecture is that those operations that are distributed over multiple services must use the eventually consistent service collaboration patterns.
For example, a system command, such a createOrder()
, that updates multiple services would need to be implemented as a Saga that consists of the following steps (each of which is an ACID transaction):
Order Service
- create anOrder
Customer Service
- update theCustomer
to reserve credit.Notification Service
- send an email to theCustomer
.
Sagas can be complicated
On the surface, sagas might seem simple but they require you, as a developer, to deal with various issues that were previously handled by the database.
For example, you must implement compensating transactions, which undo previously committed updates.
For example, in the Create Order Saga
, the credit reservation step could fail due to insufficient credit, and so the first step would need a compensating transaction that rejects the order.
You must also handle lack of automatic isolation - sagas are ACD - by using countermeasures, which are application-level design techniques that implement isolation.
Robust asynchronous messaging is complicated
Moreover, since sagas often use asynchronous messaging for coordination you must also handle several low level issues. You must, for example, implement the Transaction outbox pattern to ensure that an operation atomically updates the database and sends a message. Also, since a message broker can deliver the same message multiple times you need to implement the Idempotent receiver pattern to ensure that message handlers are idempotent.
Lack of ACID is the price to pay for the benefits of microservices
Of course, if you have decided to use microservices for your application, then it’s because the benefits of having independently deployable services outweigh the drawbacks of some operations needing to be eventually consistent. Specifically, you use microservices instead of a monolith because it better resolves one or more of the dark energy forces. For example, improved team autonomy, faster deployment pipeline, etc.
Modular monolith: one transaction per domain?
You could implement operations in a modular monolith using one transaction per domain.
A multi-domain operation such as createOrder()
would be implemented as a saga with each step being an ACID transaction in a different domain:
Order Management
- create anOrder
Customer Management
- update theCustomer
to reserve credit.Notification Management
- send an email to theCustomer
.
The downside, however, is that, as I described above, this is much more complex than one transaction per operation.
Modular monolith: one transaction per operation
As you can see, there are compelling reasons to implement each system command as an ACID transaction. I’d argue that in a modular monolith, an operations that spans multiple domains should be implemented as a single ACID transaction. It’s simply…. err.. simpler. It avoids the complexities and overhead of patterns, such as Saga, Transaction outbox, Idempotent receiver, etc. For example, even if you use the observer pattern (e.g. pub-sub) within your monolith, then the observers should execute with the operation’s transaction.
But transactions spanning domains is not modular?
I suppose that an ACID transaction span multiple domains might violate encapsulation. But on the other hand, I’m not sure what problems that typically creates. Conversely, limiting each transaction to a single domain demonstrably adds complexity without any of the upsides of microservices.
But what about one DDD aggregate per transaction?
Interestingly, there are some in the DDD community that argue that a database should only update a single aggregate. The basis of this argument is the rule that an aggregate is a consistency boundary. While this rule makes sense, I don’t interpret as requiring a database transaction to a single aggregate. An aggregate is simply responsible for maintaining the consistency of its constituent entities and value objects.
When to use eventual consistency
Of course, very few design rules are absolute.
You should consider breaking one command = one transaction
rule when it creates a tangible problem.
But you need to be sure that it’s worth the complexity and overhead of using eventual consistency.
Moreover, I’d try to avoid using compensating transactions. You should structure the Saga so there are no compensatable transactions. Its first step, which might span multiple domains, is the only one that can fail due to business rule violations - it’s the saga’s pivot transaction. The remaining steps are retriable transactions, which cannot violate business rules, and could be triggered via pub-sub.
For example, the Create Order Saga
consists of the following steps (each an ACID transaction):
Customer Management
andOrder Management
- create anOrder
, update theCustomer
to reserve credit and publish an event (using the Transaction Outbox pattern) that triggers step 2Notification Management
- send an email to the customer.
The first step implements the business rules that could cause the operation to fail. Since it uses the Transaction Outbox pattern, the second second is only trigger if the first step succeeds.
In summary: in a modular monolith avoid eventual consistency unless it solves a problem
Life’s too short. Keep things simple as possible. Solve actual business problems.
What’s next
In this article, I described the trade-offs of using ACID transactions and Sagas to implement system commands that update multiple domains. In a later articles, I’ll discuss other related modular monolith design issues including how to implement queries and why ‘modular monolith’ might not be the best name for this architecture style.
Need help with accelerating software delivery?
I’m available to help your organization improve agility and competitiveness through better software architecture: training workshops, architecture reviews, etc.