author-avatar
Dominik Martyniak
Pattern 9 minut

Transactional Outbox Pattern w Spring Boot z Apache Kafka – ciekawe wykorzystanie Spring Event Sourcingu

Jak połączyć Outbox z Event Sourcingiem w Spring Boot

W świecie mikroserwisów komunikacja asynchroniczna to fundament. Zamiast wywołań REST synchronizujemy się zdarzeniami przez brokera wiadomości, takiego jak Apache Kafka. Problemem jest spójność: co jeśli nasza transakcja w bazie się uda, ale komunikat do Kafki już nie? Albo odwrotnie?

Transactional Outbox Pattern bez persystencji

Transactional Outbox Pattern rozwiązuje problem utraty spójności pomiędzy bazą danych a brokerem zdarzeń. Klasyczna implementacja zakłada persystencję w tabeli outbox_event, z której dedykowany procesor lub mechanizm CDC publikuje komunikaty do brokera (np. Apache Kafka).

Chcę jednak pokazać, że w wielu przypadkach biznesowych wystarczy uproszczone podejście: zamiast utrzymywać dodatkową tabelę, można od razu wysłać zdarzenie po zatwierdzeniu transakcji. Do tego idealnie nadaje się adnotacja @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), która gwarantuje, że publikacja nastąpi dopiero wtedy, gdy transakcja w bazie zostanie pomyślnie zakończona.

Kiedy takie podejście ma sens?
Można je zastosować tylko wtedy, gdy pozwalają na to wymagania biznesowe — czyli w sytuacjach, w których:
  • pełny audyt wszystkich zdarzeń nie jest konieczny,
  • najważniejsze jest niskie opóźnienie i prostsza architektura,
  • akceptujemy, że niezawodność zależy od konfiguracji producenta i konsumenta (retry, idempotencja, DLT).

To alternatywne podejście nie zastępuje klasycznego Transactional Outbox, ale stanowi jego lekką wersję, która dobrze sprawdza się w mniej krytycznych scenariuszach albo w systemach, w których prostota i szybkość są ważniejsze niż audytowalność i pełna odporność na awarie.

Scenariusz: bank i raportowanie

Wyobraźmy sobie prosty system:

  • Account Service — zmienia saldo konta bankowego,
  • Reporting Service — nasłuchuje zdarzeń i generuje raporty.

Gdy klient wpłaca pieniądze, Account Service musi:

  1. Zaktualizować saldo w bazie.
  2. Wysłać zdarzenie AccountBalanceChanged do Kafki.

Nie możemy tego robić „na żywo” w jednej metodzie, bo ryzykujemy niespójność. Rozwiązanie: emitujemy zdarzenie domenowe i pozwalamy Springowi obsłużyć je dopiero po zatwierdzeniu transakcji.

Event sourcing light w Springu

Spring wspiera zdarzenia domenowe przez:

  • ApplicationEventPublisher — do publikacji,
  • @TransactionalEventListener — do obsługi z uwzględnieniem fazy transakcji.
Poniższy kod to przykład, który ilustruje działanie koncepcji
public class AccountBalanceChanged extends ApplicationEvent {

    private final UUID accountId;
    private final BigDecimal delta;
    private final BigDecimal newBalance;

    public AccountBalanceChanged(Object source, UUID accountId, BigDecimal delta, BigDecimal newBalance) {
        super(source);
        this.accountId = accountId;
        this.delta = delta;
        this.newBalance = newBalance;
    }
}

@Service
public class AccountService {
  private final AccountRepository accounts;
  private final ApplicationEventPublisher publisher;

  public AccountService(AccountRepository accounts, ApplicationEventPublisher publisher) {
    this.accounts = accounts;
    this.publisher = publisher;
  }

  @Transactional
  public void applyDelta(UUID accountId, BigDecimal delta) {
    Account acc = accounts.findById(accountId).orElseThrow();
    BigDecimal newBalance = acc.getBalance().add(delta);
    acc.setBalance(newBalance);
    // Emitujemy zdarzenie — ale nie trafia od razu do Kafki
    publisher.publishEvent(new AccountBalanceChanged(accountId, delta, newBalance));
  }
}

@TransactionalEventListener i fazy transakcji

Spring pozwala wybrać moment obsługi zdarzenia:

  • BEFORE_COMMIT — przed commit,
  • AFTER_COMMIT — po commit
  • AFTER_ROLLBACK — tylko przy rollback,
  • AFTER_COMPLETION — zawsze na koniec.
FAQ rekrutacyjne: „W której fazie wysyłasz zdarzenia do Kafki?” — Odpowiedź: Zdarzenia do Kafki wysyłam w fazie BEFORE_COMMIT, ponieważ traktuję wysyłkę jako integralną część transakcji. Dzięki temu, jeśli z jakiegokolwiek powodu wysyłka do Kafki się nie uda, cała transakcja w bazie zostanie wycofana. Unikam w ten sposób sytuacji, w której dane w bazie zostałyby zatwierdzone, ale komunikat do Kafki by nie trafił – czyli miałbym niespójność między systemami.

Wysyłka do Kafki

  private final ObjectMapper mapper = new ObjectMapper();

  public KafkaEventPublisher(KafkaTemplate<String, String> kafka) {
    this.kafka = kafka;
  }

  @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
  public void on(AccountBalanceChanged event) throws JsonProcessingException {
    String key = event.accountId().toString();
    String payload = mapper.writeValueAsString(event);
    kafka.send("account-events", key, payload)
         .addCallback(
           r -> System.out.println("Event wysłany: " + event),
           ex -> System.err.println("Błąd wysyłki: " + ex.getMessage())
         );
  }
} 

Uwaga: Nie ma bufora — jeśli Kafka nie działa, musimy polegać na retry, DLT i konfiguracji producenta.

Konsument w Reporting Service

@Service
public class ReportingListener {

  @KafkaListener(topics = "account-events", groupId = "reporting")
  public void consume(String message) throws JsonProcessingException {
    ObjectMapper mapper = new ObjectMapper();
    AccountBalanceChanged event = mapper.readValue(message, AccountBalanceChanged.class);
    System.out.println("Raportowanie zmiany salda: " + event);
  }
}
 
!UWAGA!

Konsument musi być idempotentny — zdarzenie może dotrzeć dwa razy. Typowy trik: deduplikacja po eventId albo kluczu biznesowym.

Zalety i wady podejścia bez persystencji

Zalety

  • Brak dodatkowej tabeli — prostsza architektura,
  • Niższe opóźnienie — zdarzenie idzie od razu do brokera,
  • Mniej kodu do utrzymania.

Wady

  • Brak bufora — jeśli Kafka padnie, zdarzenie może przepaść,
  • Potrzebne retry i DLT,
  • Trudniej audytować historię zdarzeń.

Pytania rekrutacyjne

Kiedy użyjesz AFTER_COMMIT, a kiedy BEFORE_COMMIT?

AFTER_COMMIT (domyślna)

  • Handler uruchamia się po pomyślnym zatwierdzeniu transakcji.
  • Bezpieczne dla działań poza DB: publikacja do Kafki/RabbitMQ, e-maile, powiadomienia.
  • Gwarantuje, że zdarzenie nie wyjdzie, jeśli transakcja w bazie została wycofana.
  • Najczęściej stosowana faza.

BEFORE_COMMIT

  • Handler uruchamia się tuż przed commit w obrębie transakcji.
  • Jeśli handler rzuci wyjątek, cała transakcja zostaje wycofana.
  • Używaj, gdy operacja jest integralną częścią transakcji (walidacje, przygotowania danych, dodatkowe zapisy w DB).
  • Ryzyko: blokujesz commit bazodanowy błędem zewnętrznego systemu (np. chwilowy problem z Kafką).

Co się stanie przy rollbacku?

  • AFTER_COMMIT: handler nie wykona się — np. Kafka nie dostanie eventu.
  • BEFORE_COMMIT: handler mógł się uruchomić, ale jeśli dojdzie do wyjątku/rollbacku, transakcja zostanie wycofana. Działań trwałych poza DB nie powinno się tu wykonywać.

Różnica między @EventListener a @TransactionalEventListener

@EventListener

  • Działa natychmiast, w tym samym wątku co publikacja zdarzenia.
  • Brak świadomości transakcji — nie wpływa na rollback bieżącej transakcji.
  • Dobre do zdarzeń aplikacyjnych niezależnych od DB.

@TransactionalEventListener

  • Świadomy transakcji; obsługuje fazy: BEFORE_COMMIT, AFTER_COMMIT (domyślna), AFTER_ROLLBACK, AFTER_COMPLETION.
  • Idealny do zdarzeń domenowych spójnych z DB (np. publikacja do Kafki po commicie).

Podsumowanie

W naszym przykładzie Transactional Outbox bez dedykowanej bazy danych to lekka i praktyczna odmiana klasycznego wzorca. Zamiast utrzymywać dodatkową persystencję, wykorzystujemy mechanizm @TransactionalEventListener w fazie BEFORE_COMMIT, co gwarantuje, że wysyłka zdarzeń do Apache Kafka jest częścią transakcji. Dzięki temu, jeśli wysyłka się nie powiedzie, cała transakcja zostanie wycofana, a system pozostaje spójny.

4
[ spring event, event sourcing, java, pattern]

Więcej od Dominik Martyniak

Więcej artykułów