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:
- Zaktualizować saldo w bazie.
- 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.