DEV Community

Thellu
Thellu

Posted on

Don’t Publish Events Inside Your Transaction: The Transactional Outbox Pattern in Spring Boot

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()));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Save data to the database
  2. Publish an event to Kafka, RabbitMQ, SNS/SQS, or another messaging system

For example:

Create order
→ insert row into orders table
→ publish OrderCreated event
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()));
}
Enter fullscreen mode Exit fullscreen mode

Imagine this happens:

1. Order row is inserted
2. Transaction commits
3. Kafka publish fails due to timeout
Enter fullscreen mode Exit fullscreen mode

Now your database says:

order exists
Enter fullscreen mode Exit fullscreen mode

But downstream systems never hear:

OrderCreated
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Downstream systems receive:

OrderCreated(orderId=123)
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. business data
  2. outbox event record

Example:

Transaction:
  insert into orders
  insert into outbox_events
commit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
) {}
Enter fullscreen mode Exit fullscreen mode

A consumer can store processed event IDs:

CREATE TABLE processed_event (
  event_id     UUID PRIMARY KEY,
  processed_at TIMESTAMP NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

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()));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That leaks implementation details into your integration contract.

Prefer stable event names:

OrderCreated
OrderCancelled
PaymentCaptured
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Keep enough data for debugging, but do not let it become your largest table by accident.

4) Forgetting observability

You need metrics:

  • number of NEW events
  • 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
Enter fullscreen mode Exit fullscreen mode

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");
  }
}
Enter fullscreen mode Exit fullscreen mode

Also test rollback behavior:

@Test
void whenOrderCreationFails_outboxEventShouldRollbackToo() {
  assertThatThrownBy(() -> orderService.createOrderWithFailure(...))
      .isInstanceOf(RuntimeException.class);

  assertThat(orderRepository.findAll()).isEmpty();
  assertThat(outboxRepository.findAll()).isEmpty();
}
Enter fullscreen mode Exit fullscreen mode

That second test proves the most important property:

No business commit, no event.


Production checklist

Before shipping an outbox publisher, check:

  1. Are business row and outbox row written in the same transaction?
  2. Does every event have a stable event ID?
  3. Can the publisher retry safely?
  4. Can consumers handle duplicate events?
  5. Is there a cleanup/archive strategy?
  6. Is there an alert for old unpublished events?
  7. Does the publisher handle broker downtime?
  8. Is the event payload a stable contract rather than an internal entity dump?
  9. Are failed events visible to support/on-call engineers?
  10. 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
alexshev profile image
Alex Shev

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.