If you’ve worked with microservices, you’ve probably run into this problem:
“I need to update my database and publish an event. How do I make sure one succeeds without the other getting lost?”
For example, imagine an Order Service:
- Customer places an order.
- The service saves the order to the database.
- The service publishes an
OrderCreatedevent to Kafka, RabbitMQ, or another broker. - Payment, inventory, shipping, and notification services react to that event.
At first glance, this looks easy:
save order
publish event
But distributed systems have an annoying habit of failing at exactly the wrong moment.
What happens if the database update succeeds but the application crashes before publishing the event?
Or what if the event is published successfully, but the database transaction rolls back?
You now have two systems that disagree about reality.
This is commonly called the dual-write problem.
Two patterns are particularly useful for solving it:
- Transactional Outbox
- Event Sourcing
They solve related problems, but they are fundamentally different approaches.
Two battle-tested architectural patterns solve this problem:
Transactional Outbox: The database remains the source of truth, and events are a reliable side effect of database changes.
Event Sourcing: Events become the source of truth, and the current state is derived from those events.
Let’s break that down.
Transactional Outbox Pattern
The Transactional Outbox pattern maintains a conventional state-based database while reliably publishing domain events to external systems.
Instead of publishing an event directly to a message broker, you save the event into an outbox table within the same local database transaction used to update your business data. A separate background process (a relay worker or Change Data Capture (CDC) engine like Debezium) polls or streams from the outbox table and forwards the events to the message broker.
[ Application Service ]
│
▼ (Single ACiD Transaction)
┌──────────────────────────────────────┐
│ Database │
│ ├── [ Orders Table ] (Update State) │
│ └── [ Outbox Table ] (Save Event) │
└──────────────────────────────────────┘
│
▼ (Async Read/Poll)
[ Outbox Relay / CDC Worker ] ──► [ Message Broker (Kafka) ]
The important part is that the order and the outbox record are committed together.
A Simple Outbox Example
Imagine these tables:
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
total DECIMAL(10,2) NOT NULL,
status VARCHAR(50) NOT NULL
);
CREATE TABLE outbox (
id UUID PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
aggregate_id UUID NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL
);
Creating an order now looks like:
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = new Order(
command.customerId(),
command.total()
);
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"OrderCreated",
order.getId(),
serialize(order)
);
outboxRepository.save(event);
}
The key is the @Transactional.
Both operations happen inside the same database transaction:
BEGIN TRANSACTION
INSERT INTO orders ...
INSERT INTO outbox ...
COMMIT
If the transaction fails:
orders insert → rolled back
outbox insert → rolled back
If it succeeds:
orders insert → committed
outbox insert → committed
The message broker isn’t involved yet.
A separate process—the message relay—reads the outbox and publishes events.
@Scheduled(fixedDelay = 1000)
public void publishEvents() {
List<OutboxEvent> events =
outboxRepository.findUnpublished();
for (OutboxEvent event : events) {
kafka.publish(
event.eventType(),
event.payload()
);
outboxRepository.markPublished(event.id());
}
}
The relay can be implemented using polling, or the database transaction log can be tailed by infrastructure such as CDC tooling. These are recognized variants of the transactional messaging approach.
What If the Message Relay Crashes?
There’s an important catch.
Imagine:
1. Read event from outbox
2. Publish event to Kafka Pass
3. Application crashes Fail
4. Mark event as published Fail
When the relay restarts, it sees the event as unpublished and sends it again.
So the consumer might receive:
OrderCreated
OrderCreated
This means a Transactional Outbox generally gives you at-least-once delivery, rather than magically guaranteeing exactly-once processing.
Your consumers should therefore be idempotent.
For example:
public void handle(OrderCreated event) {
if (processedEvents.exists(event.id())) {
return;
}
inventory.reserve(event.orderId());
processedEvents.save(event.id());
}
The consumer can safely receive the same event multiple times.
This duplicate-delivery issue is a known consideration of the pattern.
Pros & Cons
| Pros | Cons |
| Simple mental model: Preserves classic CRUD pattern and relational schemas. | Outbox table growth: Requires cleanup routines to clear processed rows. |
| At-least-once delivery: Guarantees no event is lost due to crash/rollback. | Polling overhead: Direct table polling adds query load (CDC mitigates this). |
| Easy adoption: Simple to introduce to existing monolithic or CRUD apps. | Duplicate events: Consumers must handle duplicate messages (idempotency). |
Event Sourcing Pattern
Event Sourcing flips traditional persistence on its head. Instead of storing current state (e.g., an Orders table with updated columns), you store every state change as an immutable sequence of events in an Event Store.
The current state of an entity is derived dynamically by replaying all historical events from the beginning of time (or from the latest snapshot).
[ Application Service ] ──► [ Event Store (Append-Only Log) ]
│
├─► [ Event Bus / Consumers ]
└─► [ Read Models / Projections (CQRS) ]
Instead of storing the current state:
orders
id status total
123 SHIPPED $100
you store the events that caused the state to change:
OrderCreated
OrderPaid
OrderPacked
OrderShipped
The event stream becomes the source of truth. Event Sourcing stores the history as the primary persistence model. The current state can be reconstructed by replaying the events.
A Simple Event-Sourced Example
Instead of:
UPDATE orders
SET status = 'PAID'
WHERE id = '123';
we append an event:
{
"eventType": "OrderPaid",
"orderId": "123",
"amount": 100.00
}
The event store might contain:
Order 123
1. OrderCreated
2. OrderPaid
3. OrderPacked
4. OrderShipped
The application can reconstruct the order:
Order order = new Order();
List<Event> events =
eventStore.getEvents(orderId);
for (Event event : events) {
order.apply(event);
}
For example:
public void apply(Event event) {
switch (event.type()) {
case "OrderCreated" ->
status = PENDING;
case "OrderPaid" ->
status = PAID;
case "OrderPacked" ->
status = PACKED;
case "OrderShipped" ->
status = SHIPPED;
}
}
The important idea is that you don’t overwrite history. You append to it.
Event Sourcing and CQRS
Event Sourcing frequently goes hand in hand with CQRS.
Why?
Because event stores aren’t always great at answering queries like:
SELECT *
FROM orders
WHERE customer_id = ?
ORDER BY created_at DESC;
You could replay thousands of events to reconstruct every order, but that isn’t a great query strategy.
Instead, you might have:
Event Store
│
│ events
▼
┌──────────────┐
│ Event Handler│
└───────┬──────┘
│
▼
Read Database
The read database contains optimized projections:
orders_view
order_id | customer_id | status | total
When an event arrives:
OrderShipped
│
▼
Update orders_view
This gives you fast queries while retaining the event stream as the source of truth.
The trade-off is that your read models are typically eventually consistent.
Pros & Cons
| Pros | Cons |
| Complete audit log: Inherent historical lineage—you can restore state at any point in time. | Steep learning curve: High complexity compared to traditional ORM schemas. |
| Built-in event-driven architecture: No need to detect state changes; changes are events. | Schema evolution: Changing event formats over time requires careful versioning/upcasting. |
| Time-travel debugging: Easily replay past events to debug issues or create new read projections. | Requires CQRS: Querying aggregate collections directly becomes impractical without separate read stores. |
The Most Important Difference
Here’s the easiest mental model.
Transactional Outbox
SOURCE OF TRUTH
│
▼
┌──────────┐
│ Database │
└────┬─────┘
│
Outbox Event
│
▼
Message Broker
The database contains your business state.
The outbox exists to reliably publish changes.
Event Sourcing
SOURCE OF TRUTH
│
▼
┌───────────┐
│ Events │
└─────┬─────┘
│
┌──────┴──────┐
▼ ▼
Current State Read Models
The events are the business history.
The current state is derived from them.
Side-by-Side Comparison
| Transactional Outbox | Event Sourcing | |
|---|---|---|
| Primary source of truth | Current database state | Event stream |
| Stores current state | Yes | Usually derived |
| Stores history | Usually limited | Yes |
| Main purpose | Reliable event publishing | Event-based persistence |
| Complexity | Lower | Higher |
| Easy to add to existing CRUD app | Yes | Usually no |
| Replay events | Limited | Core capability |
| Audit history | Requires additional work | Natural |
| CQRS required | No | Often useful |
| Eventual consistency | Usually downstream | Common throughout read side |
| Best for | Integration events | Domain history and replay |
| Learning curve | Lower | Higher |
The distinction is important because Transactional Outbox and Event Sourcing aren’t simply two implementations of the same thing. They solve the dual-write problem at different architectural levels.
When to Use What
Use the Transactional Outbox Pattern when:
- You are refactoring an existing CRUD application or domain that already uses a traditional relational or document database.
- Your primary requirement is reliably triggering asynchronous downstream integration (e.g., sending notification emails, updating third-party search indexes).
- Your business needs to query current state efficiently without building complex CQRS infrastructure.
Use Event Sourcing when:
- Auditability, traceability, and historical lineage are core business domain requirements (e.g., banking, ledger systems, healthcare, compliance/legal tracking).
- State transitions carry rich business meaning beyond just attribute updates (e.g., complex order workflows with state replays).
- You plan to implement CQRS to support multiple custom-tailored read projections from a central event log.
Alternative Patterns
If neither pattern fits your constraints, here are three common alternatives:
1. Change Data Capture (CDC) direct on Domain Tables
- How it works: Tools like Debezium or AWS Database Migration Service read database transaction logs (WAL/binlog) directly from standard domain tables without needing an
outboxtable. - Best for: Existing legacy systems where application code cannot be easily altered to write to an outbox table.
2. Two-Phase Commit (2PC) / Distributed Transactions
- How it works: Coordinates transactions across multiple resources (e.g., a SQL database and a message broker) using protocols like WS-AtomicTransaction or XA.
- Best for: Strictly synchronous consistency across systems. Note: Generally discouraged in modern microservice architectures due to high latency and tight coupling.
3. Saga Pattern (Choreography or Orchestration)
- How it works: Manages long-running distributed business transactions as a sequence of local transactions, executing compensating transactions if a step fails.
- Best for: Complex cross-microservice workflows that need consistency across multiple independent services without relying on a central database transaction.
Final Recommendation
If you’re starting a new microservice and you’re wondering which one to choose, don’t start with Event Sourcing just because it sounds more sophisticated.
Start by asking:
Question 1
Do I need to reliably publish events when my database changes?
If yes:
Transactional Outbox
is usually the simpler choice.
Question 2
Do I need the complete history of business events as the source of truth?
If yes, investigate:
Event Sourcing
Question 3
Do I need to coordinate a business transaction across several services?
Consider:
Saga
Question 4
Do I already have a database and want to stream its changes?
Consider:
CDC / Transaction Log Tailing
Question 5
Do I need different read models optimized for different consumers?
Consider:
CQRS
—-
Further Reading: Imposter Syndrome in Tech: How to Overcome It
Discover more from TACETRA
Subscribe to get the latest posts sent to your email.