There is a common bug hiding in many backend services:
@Transactional
public void createOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request));
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
}
It looks clean.
The order is saved.
The event is published.
Everything is inside one service method.
But this code has a dangerous question:
What happens if the database transaction commits, but event publishing fails?
Or the opposite:
What happens if the event is published, but the database transaction rolls back?
That is the classic dual-write problem.
This post explains why publishing events directly inside a transaction is risky, and how the Transactional Outbox Pattern makes the flow safer.
The problem: one business action, two systems
When a service creates an order, it often needs to do two things:
- Save data to the database
- Publish an event to Kafka, RabbitMQ, SNS/SQS, or another messaging system
For example:
Create order
→ insert row into orders table
→ publish OrderCreated event
The problem is that the database and the message broker are two different systems.
A normal local database transaction cannot atomically commit both:
DB commit + message publish
So if you write to the DB and publish to a broker in the same method, you still do not have one true atomic transaction across both systems.
That is where things get messy.
Failure scenario 1: DB commits, event publish fails
@Transactional
public void createOrder(CreateOrderRequest request) {
Order order = orderRepository.save(new Order(request));
kafkaTemplate.send("orders", new OrderCreatedEvent(order.getId()));
}
Imagine this happens:
1. Order row is inserted
2. Transaction commits
3. Kafka publish fails due to timeout
Now your database says:
order exists
But downstream systems never hear:
OrderCreated
Maybe inventory is not reserved.
Maybe email is not sent.
Maybe analytics is missing the order.
Maybe another service never starts its workflow.
The database and the event stream are now inconsistent.
Failure scenario 2: event publishes, DB rolls back
Now imagine the opposite:
1. Event is published
2. Something fails later in the transaction
3. DB transaction rolls back
Downstream systems receive:
OrderCreated(orderId=123)
But the order does not exist in the database.
Now consumers are reacting to a fact that never became true.
That is even worse.
The tempting but incomplete fix: publish after commit
Spring has tools like @TransactionalEventListener(phase = AFTER_COMMIT).
That can help avoid publishing events before the transaction commits.
Example:
@Component
public class OrderEventListener {
private final KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate;
public OrderEventListener(KafkaTemplate<String, OrderCreatedEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
kafkaTemplate.send("orders", event);
}
}
This is better than publishing before commit.
But it does not fully solve the problem.
If the transaction commits and then Kafka is down, the event still fails to publish.
And unless you persist the event somewhere, you may lose it.
So AFTER_COMMIT is useful, but it is not a durable reliability pattern by itself.
The outbox idea
The Transactional Outbox Pattern changes the flow.
Instead of writing to the database and publishing to the broker in the same step, you write two rows to the same database transaction:
- business data
- outbox event record
Example:
Transaction:
insert into orders
insert into outbox_events
commit
Then a separate background publisher reads unsent outbox rows and publishes them to the message broker.
Outbox publisher:
read unpublished outbox events
publish to broker
mark as published
This gives you a safer guarantee:
If the order commits, the outbox event commits with it.
The event is now durable.
If the broker is down, the event stays in the database and can be retried later.
Outbox table design
A simple table can look like this:
CREATE TABLE outbox_event (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
next_retry_at TIMESTAMP NULL,
published_at TIMESTAMP NULL
);
Example row:
id: 7b9c...
aggregate_type: ORDER
aggregate_id: 123
event_type: OrderCreated
payload: {"orderId":"123","customerId":"c_456"}
status: NEW
retry_count: 0
created_at: 2026-08-09T15:00:00Z
The exact schema can vary, but you usually want:
- a unique event ID
- the aggregate type and ID
- the event type
- the serialized payload
- publish status
- retry metadata
- timestamps
Step 1) Define the outbox entity
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
@Id
private UUID id;
@Column(nullable = false)
private String aggregateType;
@Column(nullable = false)
private String aggregateId;
@Column(nullable = false)
private String eventType;
@Lob
@Column(nullable = false)
private String payload;
@Column(nullable = false)
private String status;
@Column(nullable = false)
private int retryCount;
@Column(nullable = false)
private Instant createdAt;
private Instant nextRetryAt;
private Instant publishedAt;
protected OutboxEvent() {
}
public static OutboxEvent newEvent(
String aggregateType,
String aggregateId,
String eventType,
String payload
) {
OutboxEvent e = new OutboxEvent();
e.id = UUID.randomUUID();
e.aggregateType = aggregateType;
e.aggregateId = aggregateId;
e.eventType = eventType;
e.payload = payload;
e.status = "NEW";
e.retryCount = 0;
e.createdAt = Instant.now();
return e;
}
public void markPublished() {
this.status = "PUBLISHED";
this.publishedAt = Instant.now();
}
public void markFailedForRetry(Instant nextRetryAt) {
this.status = "NEW";
this.retryCount++;
this.nextRetryAt = nextRetryAt;
}
public UUID getId() {
return id;
}
public String getPayload() {
return payload;
}
public String getEventType() {
return eventType;
}
}
Step 2) Create the repository
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, UUID> {
@Query("""
select e
from OutboxEvent e
where e.status = 'NEW'
and (e.nextRetryAt is null or e.nextRetryAt <= :now)
order by e.createdAt asc
""")
List<OutboxEvent> findReadyToPublish(@Param("now") Instant now, Pageable pageable);
}
In a real multi-instance system, you should also think about locking.
For PostgreSQL, many teams use FOR UPDATE SKIP LOCKED to avoid multiple workers publishing the same event at the same time.
The concept is:
worker 1 locks rows 1-100
worker 2 skips those locked rows and picks the next batch
The exact implementation depends on your database and JPA/native query preferences.
Step 3) Write business data and outbox event in one transaction
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxRepository;
private final ObjectMapper objectMapper;
public OrderService(
OrderRepository orderRepository,
OutboxEventRepository outboxRepository,
ObjectMapper objectMapper
) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
this.objectMapper = objectMapper;
}
@Transactional
public OrderResponse createOrder(CreateOrderRequest request) {
Order order = new Order(request.customerId(), request.items());
orderRepository.save(order);
OrderCreatedEvent event = new OrderCreatedEvent(
UUID.randomUUID().toString(),
order.getId().toString(),
order.getCustomerId()
);
String payload = toJson(event);
outboxRepository.save(OutboxEvent.newEvent(
"ORDER",
order.getId().toString(),
"OrderCreated",
payload
));
return new OrderResponse(order.getId().toString(), "CREATED");
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("Failed to serialize outbox event", ex);
}
}
}
Now the order and event record commit together.
If the transaction rolls back, both disappear.
That is exactly what we want.
Step 4) Publish outbox events in the background
A simple scheduled publisher:
@Component
public class OutboxPublisher {
private static final Logger log = LoggerFactory.getLogger(OutboxPublisher.class);
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
public OutboxPublisher(
OutboxEventRepository outboxRepository,
KafkaTemplate<String, String> kafkaTemplate
) {
this.outboxRepository = outboxRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelayString = "${outbox.publisher.delay-ms:1000}")
public void publishBatch() {
List<OutboxEvent> events = outboxRepository.findReadyToPublish(
Instant.now(),
PageRequest.of(0, 100)
);
for (OutboxEvent event : events) {
publishOne(event);
}
}
@Transactional
public void publishOne(OutboxEvent event) {
try {
kafkaTemplate.send("orders", event.getPayload()).get();
event.markPublished();
outboxRepository.save(event);
log.info("Published outbox event: id={}, type={}", event.getId(), event.getEventType());
} catch (Exception ex) {
event.markFailedForRetry(Instant.now().plusSeconds(30));
outboxRepository.save(event);
log.warn("Failed to publish outbox event: id={}, type={}, willRetry=true",
event.getId(),
event.getEventType(),
ex);
}
}
}
This is intentionally simple.
In a real service, you may want:
- batch publishing
- exponential backoff
- max retry count
- dead-letter handling
- metrics
- database row locking
- separate transactions per event
- idempotent consumers
But the core idea stays the same.
Important: publishing and marking as published is still not perfectly atomic
The outbox pattern reduces the dual-write problem, but it does not magically make the broker and database one transaction.
There is still a possible failure:
1. Event is published to Kafka
2. Service crashes before marking outbox row as PUBLISHED
3. Publisher retries later
4. Same event is published again
That means consumers must be prepared for duplicates.
This is why outbox is usually paired with idempotent consumers.
The producer side gives you durable eventual publishing.
The consumer side must handle at-least-once delivery.
Consumer idempotency
Every event should have a stable event ID.
Example:
public record OrderCreatedEvent(
String eventId,
String orderId,
String customerId
) {}
A consumer can store processed event IDs:
CREATE TABLE processed_event (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
Before processing:
@Transactional
public void handle(OrderCreatedEvent event) {
if (processedEventRepository.existsById(event.eventId())) {
return;
}
inventoryService.reserve(event.orderId());
processedEventRepository.save(new ProcessedEvent(event.eventId(), Instant.now()));
}
This makes duplicate delivery safe.
Do not assume “the broker will never redeliver”.
Assume duplicates can happen.
What about @TransactionalEventListener?
@TransactionalEventListener(phase = AFTER_COMMIT) is still useful.
But I usually use it for in-process follow-up work where losing the action is acceptable or recoverable.
For durable cross-service events, I prefer an outbox row.
A rough rule:
In-process side effect:
@TransactionalEventListener may be enough
Cross-service durable event:
transactional outbox is safer
If another service depends on that event to stay consistent, write it to the outbox.
What about Debezium / CDC?
Another common outbox implementation is:
Application writes outbox row
Debezium reads DB changes
Debezium publishes event to Kafka
This removes the need for an application-level polling publisher.
The application only needs to write the outbox row. CDC handles publishing.
That can be very robust, but it also adds infrastructure complexity.
For smaller systems, a scheduled publisher is easier to understand.
For larger event-driven systems, CDC-based outbox is often worth considering.
Common mistakes
1) Publishing first, then saving the outbox row
This defeats the purpose.
The outbox row should be written in the same transaction as the business data.
2) Storing Java class names as event types
Avoid event types like:
com.example.order.internal.OrderCreatedEvent
That leaks implementation details into your integration contract.
Prefer stable event names:
OrderCreated
OrderCancelled
PaymentCaptured
3) Treating outbox as a queue forever
The outbox table should not grow forever.
You need cleanup or archival:
DELETE FROM outbox_event
WHERE status = 'PUBLISHED'
AND published_at < now() - interval '7 days';
Keep enough data for debugging, but do not let it become your largest table by accident.
4) Forgetting observability
You need metrics:
- number of
NEWevents - oldest unpublished event age
- publish success count
- publish failure count
- retry count
- dead-letter count
The most important alert is usually:
oldest unpublished event age is too high
That tells you the publisher is stuck or the broker is unavailable.
Testing the outbox flow
A useful service test verifies that both the order and outbox row are created in one transaction.
@SpringBootTest
class OrderServiceOutboxTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OutboxEventRepository outboxRepository;
@Test
void createOrder_shouldCreateOutboxEvent() {
OrderResponse response = orderService.createOrder(
new CreateOrderRequest("customer-1", List.of("sku-1"))
);
assertThat(orderRepository.findById(UUID.fromString(response.orderId()))).isPresent();
List<OutboxEvent> events = outboxRepository.findAll();
assertThat(events).hasSize(1);
assertThat(events.get(0).getEventType()).isEqualTo("OrderCreated");
}
}
Also test rollback behavior:
@Test
void whenOrderCreationFails_outboxEventShouldRollbackToo() {
assertThatThrownBy(() -> orderService.createOrderWithFailure(...))
.isInstanceOf(RuntimeException.class);
assertThat(orderRepository.findAll()).isEmpty();
assertThat(outboxRepository.findAll()).isEmpty();
}
That second test proves the most important property:
No business commit, no event.
Production checklist
Before shipping an outbox publisher, check:
- Are business row and outbox row written in the same transaction?
- Does every event have a stable event ID?
- Can the publisher retry safely?
- Can consumers handle duplicate events?
- Is there a cleanup/archive strategy?
- Is there an alert for old unpublished events?
- Does the publisher handle broker downtime?
- Is the event payload a stable contract rather than an internal entity dump?
- Are failed events visible to support/on-call engineers?
- Can you replay events if needed?
If the answer to most of these is yes, your event publishing is much safer than direct publish-inside-transaction code.
Rule of thumb
If the event is only a local implementation detail, direct Spring events may be fine.
If the event is part of cross-service consistency, use an outbox.
Save business data
Save outbox event
Commit once
Publish later
Retry until delivered
Make consumers idempotent
That is the core pattern.
Wrap-up
Publishing an event inside a transaction looks clean, but it can hide a reliability bug.
The database can commit while the broker publish fails.
The broker can receive an event while the database rolls back.
Retries can create duplicates.
The Transactional Outbox Pattern makes the important part durable:
The fact that an event needs to be published is stored in the same transaction as the business change.
It does not remove every distributed systems problem.
But it turns a dangerous dual-write into a controlled, observable, retryable workflow.
That is a big upgrade.
Top comments (1)
The outbox pattern is one of those boring architecture choices that saves you from very non-boring incidents. Publishing inside the transaction feels direct, but it couples database truth to network behavior. I like that this pattern turns "did we commit?" and "did we notify?" into separately recoverable states.