Introduction
In distributed systems, one of the hardest problems is guaranteeing data consistency when publishing events.
Imagine the following scenario:
- Your API saves data in the database.
- It publishes an event to a message broker (Kafka, RabbitMQ, etc).
What happens if:
- The database transaction succeeds
- But the event fails to publish?
Now your system is in an inconsistent state.
This is exactly the problem the Transactional Outbox Pattern solves.
In this article, we will explore:
- The problem of dual writes
- How the Outbox Pattern solves it
- A practical implementation using Quarkus
- A working sample project
Repository:
👉 https://github.com/allanroberto18/outbox-sample-api
The Dual Write Problem
When an application performs two operations:
1️⃣ Save data
2️⃣ Publish an event
We call this a dual write problem.
Example:
@Transactional
public void createOrder(Order order) {
orderRepository.persist(order);
eventPublisher.publish(
new OrderCreatedEvent(order.getId())
);
}
If the event fails to publish, the database is already committed.
This leads to:
❌ Lost events
❌ Inconsistent services
❌ Broken microservice workflows
The Outbox Pattern
The Outbox Pattern solves this problem by storing events in the same database transaction as the business data.
Instead of publishing directly to a broker, we do this:
1️⃣ Save business data
2️⃣ Save event in an outbox table
3️⃣ A background worker publishes the event
Flow
- API receives request
- Database transaction saves:
- Business entity
- Outbox event
- Worker reads events from Outbox
- Events are published to message broker
- Event marked as processed
This guarantees event delivery without losing consistency.
The pattern ensures events are published only if the database transaction succeeds.
Project Overview
This repository demonstrates a simple implementation using Quarkus.
👉 https://github.com/allanroberto18/outbox-sample-api
Technologies used:
- Quarkus
- Java
- JPA / Hibernate
- Transactional Outbox Pattern
Quarkus is a Java framework optimized for cloud-native applications and microservices.
Creating the Outbox Event
First we create a table responsible for storing events.
Example entity:
@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(name = "event_type", nullable = false, length = 120)
public String eventType;
@Column(name = "payload", nullable = false, columnDefinition = "TEXT")
public String payload;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
public OutboxEventStatus status;
@Column(nullable = false)
public int attempts;
@Column(name = "error_message", length = 1000)
public String errorMessage;
@Column(name = "last_error_at")
public LocalDateTime lastErrorAt;
@Column(name = "next_attempt_at")
public LocalDateTime nextAttemptAt;
@Column(name = "max_attempts", nullable = false)
public int maxAttempts;
@Column(name = "created_at", nullable = false)
public LocalDateTime createdAt;
@Column(name = "processed_at")
public LocalDateTime processedAt;
}
This table acts as a buffer between the database and the message broker.
Saving Business Data + Event
Inside the same transaction we save:
- The entity
- The outbox event
Example:
// from RegisterUserUseCase
@Transactional
public UserResponse execute(CreateUserRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new DuplicateUsernameException(request.username());
}
UserEntity user = new UserEntity();
user.firstName = request.firstName();
user.lastName = request.lastName();
user.username = request.username();
user.password = passwordHasher.hash(request.password());
user.enabled = false;
try {
userRepository.persist(user);
userRepository.flush();
} catch (PersistenceException ex) {
if (isUniqueViolation(ex)) {
throw new DuplicateUsernameException(request.username());
}
throw ex;
}
// save outbox
userActivationOutboxService.publishUserActivationEmail(user);
return UserResponse.fromEntity(user);
}
// from UserActivationOutboxService
public void publishUserActivationEmail(UserEntity user) {
OutboxEventEntity outboxEvent = new OutboxEventEntity();
outboxEvent.eventType = EVENT_TYPE_USER_ACTIVATION_EMAIL;
outboxEvent.payload = toPayload(user);
outboxEvent.maxAttempts = maxAttempts;
outboxRepository.persist(outboxEvent);
}
Now both operations happen atomically.
If the transaction fails:
❌ No user saved
❌ No event created
Event Publisher Worker
A background job periodically reads unprocessed events.
Example:
@ApplicationScoped
public class OutboxDispatcherService {
@Inject
DispatchOutboxUseCase dispatchOutboxUseCase;
@Scheduled(every = "{outbox.dispatch.every}")
@RunOnVirtualThread
void scheduledDispatch() {
dispatchPending();
}
public void dispatchPending() {
dispatchOutboxUseCase.execute();
}
}
This worker:
1️⃣ Reads pending events
2️⃣ Publishes them
3️⃣ Marks them as processed
Benefits of the Outbox Pattern
Using this pattern provides several advantages.
Reliability
Events are not lost.
Consistency
Database and event stream stay synchronized.
Fault Tolerance
If the broker is down, events stay in the database.
Scalability
Workers can scale horizontally.
Running the Project
Clone the repository:
git clone https://github.com/allanroberto18/outbox-sample-api
Run Quarkus in dev mode:
./mvn quarkus:dev
Quarkus dev mode enables live coding, allowing code changes without restarting the application.
When Should You Use the Outbox Pattern?
Use it when:
- Building microservices
- Publishing domain events
- Integrating with Kafka / RabbitMQ
- Ensuring eventual consistency
Avoid it when:
- Systems are simple
- No asynchronous communication exists
Final Thoughts
The Transactional Outbox Pattern is one of the most important patterns for building reliable distributed systems.
It solves the dual write problem and ensures:
✔ Consistent state
✔ Reliable event publishing
✔ Resilient microservices
If you want to explore a practical implementation, check the repository:
Top comments (0)