Transactional Outbox pattern with spring-boot
We can use Spring Integration to implement the outbox pattern. This can be done by setting up an integration flow that takes the email message as input and delivers it to a JDBC-backed output with a polling handler that will send the mail.
Project setup
- Project: Maven
- Language: Java
- Spring Boot: 3.3.0
- Packaging: Jar
- Java: 21
- Dependencies:
- Spring Web
- Spring Data JPA
- Spring Integration
- Docker Compose Support
- PostgreSQL Driver
- Flyway Migration
In the generated pom.xml
, manually add the spring-integration-jdbc
dependency:
Spring Integration setup
First, we configure Spring Integration itself by adding this configuration:
SpringIntegrationConfiguration.java
This bean will persist the objects we add to the outbox Spring Integration channel in the database.
Next, we define the integration flow for the mail:
MailConfiguration.java
The configuration has 3 beans:
mailInput
: This is the input channel that will receive theMailMessage
to be sent.mailOutbox
: This is the channel that the message is routed to and will store the message using theJdbcChannelMessageStore
that we configured in theSpringIntegrationConfiguration
class.mailFlow
: This defines the actual flow from themailInput
to themailOutbox
and adds ahandle()
method that does the actual sending of the emails. It polls themailOutput
every second to see if there are mails to be sent or not. Due to thetransactional()
the message remains on themailOutbox
until the sending succeeds.
This configuration class uses 2 classes that have not been explained yet: MailMessage
and MailSender
.
The MailMessage
class is a record that contains the information needed to send the email:
MailMessage.java
Note how we need to make the class Serializable
so that Spring Integration can store it in the database.
The MailSender
is an interface that can be implemented in various ways depending on how you want to send the emails:
MailSender.java
For testing, I implemented an unreliable mail sender that logs or throws an exception randomly. In reality, you will likely use Java Mail to connect to an SMTP server, or use a service such as SendGrid or Amazon SES to send the emails.
LoggingMailSender.java
Sending the email from the application
In order to tap into the Spring Integration flow, we need to create a messaging gateway. This is done via an interface annotated with @MessagingGateway
:
Note that the name of the requestChannel
has to match with the name of the bean of our input channel in the MailConfiguration
class.
We don’t need to provide an implementation. Spring Integration will implement this at runtime for us.
An example use case that uses this gateway could look like this:
1 | Save the Order in the database. |
---|---|
2 | Compose the data for the email message. |
3 | Pass the data to the MailGateway for sending out the email. |
From the use case side, it seems like we synchronously send the email, but in reality, the MailMessage
is stored in the same transaction as the Order
and the mail itself it sent asynchronously a few moments later.
Testing time
To test that everything works, we can create a REST controller to trigger the use case:
Using the HTTP client of IntelliJ or any other tool to send out a request, we can add a few orders:
If you check the logging of the application, you will sometimes see a stack trace that the email could not be delivered, but soon after you will see a retry that most likely will succeed.
Our example here uses PostgreSQL, but if you use MySQL instead, there are a few things you need to change. Under the hood, Spring Integration uses SKIP LOCK
, but MySQL does not support this.
You can do the following to make it work with MySQL:
- Define a
TransactionInterceptor
withREAD_COMMITTED
isolation level inSpringIntegrationConfiguration
:
- Update the
mailFlow
bean to use this interceptor:
- Declare the
TransactionInterceptor
as a parameter so Spring can inject it. We need to use the qualifier to ensure we get the one we declared inSpringIntegrationConfiguration
. - Use the interceptor as an argument to the
transactional()
method.
Spring Modulith
Spring Modulith is a new project in the Spring portfolio. It is led by Oliver Drotbohm and aims to make it easier to build modular monolith applications with Spring.
Communication between modules can be done asynchronously by using the ApplicationEventPublisher
from Spring core. Spring Modulith has additional infrastructure to ensure no such event is ever lost by first storing it in the database. We can leverage this to build our outbox pattern.
Project setup
Create a Spring Boot project on start.spring.io with the following configuration:
- Project: Maven
- Language: Java
- Spring Boot: 3.3.0
- Packaging: Jar
- Java: 21
- Dependencies:
- Spring Web
- Spring Data JPA
- Spring Modulith
- Docker Compose Support
- PostgreSQL Driver
- Flyway Migration
Replace the spring-modulith-starter-jpa
with spring-modulith-starter-jdbc
:
pom.xml
In this example, we will publish an OrderCompleted
event from our usecase. The event itself is a simple record with a reference to the id of the order:
The use case publishes the event:
- Publish the
OrderCompleted
event.
We can now create a Spring component that listens for the event and sends out a mail notification:
- Mark the method as an
@ApplicationModuleListener
. This is an annotation provided by Spring Modulith and a combination of:
@Async
: because we want the mail to be send asynchrounously. We don’t want the processing of theCompleteOrder
use case to be affected by the email sending.@Transactional
: Since our listener runs in a separate thread, we should start a new transaction to get the state of theOrder
from the repository.@TransactionalEventListener
: This ensures this method is called when the transaction that contains the sending of the event is comitted. If the transaction is rolled back, our listener is not called.
We can again test this by using the IntelliJ HTTP client and notice that sometimes the mail is sent properly and sometimes it fails (since our mailsender has the ramdom failure code). If we check the database, we can see that the events are stored and marked as published or not:
id | listener_id | event_type | serialized_event | publication_date | completion_date |
---|---|---|---|---|---|
6fcaa30a-2b36-4f10-a091-4ce10ab520ea | MailNotifier.onOrderCompleted(OrderCompleted) | OrderCompleted | {“orderId”:1} | 2024-06-13 05:50:43.090615 +00:00 | 2024-06-13 05:50:43.148320 +00:00 |
ddb661ad-d567-42a9-9f90-4a62bbffb3fc | MailNotifier.onOrderCompleted(OrderCompleted) | OrderCompleted | {“orderId”:2} | 2024-06-13 05:50:57.749954 +00:00 | null |
What is nice here is that the event is serialized to JSON, so it is readable in the database what it contains. With Spring Integration, it uses Java serialization, so there you only get a meaningless blob of bytes.
Update: You can use JSON as well with Spring Integration with some additional configuration. See Spring Integration using JSON serialization for more info.
Retry failed events
Unlike with Spring Integration, there is no automatic retry, but we can easily add it.
The first way is setting a property that will retry the events on application startup:
application.properties
If you have failed events and you restart the Spring Boot application, you will notice that things are retried. However, I wonder if this is actually useful, given that normally you don’t restart an application that much.
A better way is to query for unpublished events from time to time and re-publish them. To accomplish that, we can update our MailNotifier
like this:
1 | Inject the IncompleteEventPublication interface from Spring Modulith. |
---|---|
2 | Add @Scheduled with a certain polling frequency on a public method. In our example, Spring will call this method every 5 seconds. |
3 | Republish any incomplete event that is older than 5 seconds. |
With this setup, the events that failed are retried while the application is running.
Message ordering
An important difference in the Spring Integration solution vs the Spring Modulith solution is that with Spring Integration, the order is preserved and a failure of a message will prevent processing the next message. With Spring Modulith, as the application module listeners are invoked asynchronously, the retries for the individual event publications will be executed concurrently. Thus, the order at which they eventually end up in the email server cannot be guaranteed.
In our example of sending emails, there is no need to stop a next message from being sent when a previous one fails. But in other scenarios (like putting messages on Kafka for example), you probably do care about message ordering.
Running multiple instances
Another important difference is when you run multiple instances of your application.
With Spring Integration, the email is sent from one of the instances. So no double emails, and if the one that is doing the retries fails, the other will take over automatically.
With Spring Modulith, we also don’t send double emails if nothing goes wrong. But the @Scheduled
annotation is done by both instances, resulting in double emails if you have 2 instances running. We can solve this by using ShedLock for example to only have a single instance doing the retries of the events.
Conclusion
Both Spring Integration and Spring Modulith can be used to build a Transactional Outbox to get more certainty that your main database action and any notification to an external system is in sync and does not get lost. However, the Spring Integration solution does seem to have some advantages over the Spring Modulith one.
See transactional-outbox-spring-integration and transactional-outbox-spring-modulith on GitHub for the full sources of these examples.
If you have any questions or remarks, feel free to post a comment at GitHub discussions.