Уявіть: користувач оформляє замовлення. Ви успішно зберігаєте його в базу, але повідомлення іншим сервісам не доходить через мережевий збій...
В результаті: продавець не знає про нове замовлення, рахунок не виставлений, а клієнт незадоволений.

Як це виправити раз і назавжди? Відповідь — Transactional Outbox.
Що таке Transactional Outbox?
Це простий і надійний принцип:
• В тій самій транзакції, де ми оновлюємо основні дані, ми також записуємо подію у спеціальну таблицю Outbox.
• А потім окремий процес читає ці події і відправляє їх у брокер (Kafka, RabbitMQ тощо).
Так ми гарантуємо: або все успішно, або нічого.

Реалізація
1. Створюємо таблицю для подій
CREATE TABLE outbox_event (
id UUID PRIMARY KEY,
order_id VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
processed BOOLEAN NOT NULL DEFAULT false
);
2. Пишемо Entity для OutboxEvent
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
@Id
private UUID id;
@Column(name = "order_id", nullable = false)
private String orderId;
@Column(nullable = false)
private String status;
@Column(columnDefinition = "jsonb", nullable = false)
private String payload;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Column(nullable = false)
private Boolean processed = false;
// Getters and Setters
}
3. Пишемо репозиторій для OutboxEvent
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.UUID;
public interface OutboxEventRepository extends JpaRepository<OutboxEvent, UUID> {
@Query(value = """
SELECT * FROM outbox_event
WHERE processed = false
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT :batchSize
""", nativeQuery = true)
List<OutboxEvent> findUnprocessedEventsForUpdate(@Param("batchSize") int batchSize);
}
Ти напевно здивований чому саме такий запит? Це на той випадок якщо твій сервіс задеплоєн на декілька інстансів, та необхідно уникнути конфліктів при читанні подій паралельно кількома інстансами. Завдяки SKIP LOCKED
конкуренція між інстансами вирішується автоматично.
✅ Можна масштабувати горизонтально без страху дублювання або втрати повідомлень.
4. Запис основних даних і події в одній транзакції
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
order.getId(),
"OrderCreated",
serialize(order)
);
outboxEventRepository.save(event);
}
5. Публікація подій за допомогою Scheduler
@Scheduled(fixedDelay = 5000)
@Transactional
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxEventRepository.findUnprocessedEventsForUpdate(10);
for (OutboxEvent event : events) {
messagePublisher.publish(event.getStatus(), event.getPayload());
event.setProcessed(true);
}
}
Практичні поради для продакшена
🛡 Ідемпотентність: обробники подій мають бути ідемпотентними.
🔁 Retry: якщо брокер тимчасово недоступний — передбачити повторні спроби.
🧹 Очищення Outbox: налаштувати регулярне видалення або архівацію старих подій.
🏎 Батчинг: обробляти події пачками для кращої продуктивності.
Transactional Outbox — це надійний фундамент для побудови консистентних, масштабованих і відмовостійких мікросервісів.
Правильна імплементація дозволяє тобі не боятись втрати події навіть у найскладніших розподілених середовищах.
