1. Handler zdarzenia jako bean
Umiesz już publikować przez ApplicationEventPublisher. Teraz potrzebny jest drugi koniec kabla: kto przyjmuje to zdarzenie i jak sprawić, żeby reakcje były osobnymi beanami, a nie nowym kombajnem wewnątrz jednej klasy. W ContextFlow trzymajmy na razie linię ApplicationEvent → typebezpieczny ApplicationListener<T>, bo tu powiązanie między faktem a handlerem jest widoczne maksymalnie uczciwie.
Gdy po raz pierwszy wynosimy reakcje do zdarzeń, ręka ciągnie, żeby zrobić „coś jednego, ale dużego": jeden handler, który „sam się rozeznа", co się stało, i dalej wywoła audyt, powiadomienia i wszystko inne. W praktyce to szybko zamienia się w nowy monolit — tylko teraz nie w serwisie scenariusza, lecz w „super-listenerze". W tym wykładzie celowo pójdziemy inną drogą: będziemy robić handlery małe, typebezpieczne i wąsko sfokusowane, żeby dodanie nowej reakcji wyglądało jak dodanie nowej klasy, a nie poprawianie starego „centrum wszechświata".
Wyobraź sobie, że publishEvent() to jak wiadomość na firmowym chacie: „Zamówienie zostało utworzone". Serwis scenariusza po prostu wysłał wiadomość do ogólnego kanału, a dalej każdy subskrybent czyta i robi swoje: księgowość pisze audyt, marketing wysyła powiadomienie, analityka aktualizuje statystyki. Ważne, że nadawca nie musi wiedzieć, ile subskrybentów istnieje i kim oni są.
2. ApplicationListener<T> bez magii
Interfejs ApplicationListener<T> to najprostszy sposób, żeby powiedzieć Springowi: „Chcę odbierać zdarzenia typu T". Tu prawie nie ma ukrytych reguł i adnotacyjnych efektów specjalnych: jest interfejs, jedna metoda, jeden parametr. Dla celów dydaktycznych to idealny wariant — tłumaczy mechanikę zdarzeń, a nie zmusza do zapamiętywania „jak prawidłowo postawić adnotację".
Wygląda on koncepcyjnie tak (uproszczone):
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
/**
* Listener zdarzeń Spring.
* Ważne: parametr E określa typ zdarzenia, na który zapisany jest handler.
*/
public interface ApplicationListener<E extends ApplicationEvent> {
/**
* Wywoływana przez Springa, gdy opublikowane zostanie zdarzenie odpowiedniego typu.
*/
void onApplicationEvent(E event);
}
Główna wartość to typ parametru E. Jeśli piszesz ApplicationListener<OrderCreatedEvent>, twój handler:
1) gwarantowanie dostanie właśnie OrderCreatedEvent,
2) nie będzie robił instanceof,
3) nie będzie robił downcastu,
4) nie będzie mógł „przypadkowo" zacząć obsługiwać nie te zdarzenia (przynajmniej cicho).
Żeby poczuć różnicę, warto raz zobaczyć „jak nie należy" — i dlaczego to jest nieprzyjemne.
Przykład „niezbyt": słuchamy wszystkiego i rozróżniamy przez if/else
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Rejestrujemy listener jako bean, inaczej nie zostanie wywołany
public class BigOrderEventsListener implements ApplicationListener<ApplicationEvent> {
@Override
public void onApplicationEvent(ApplicationEvent event) {
// Źle: zapisaliśmy się na WSZYSTKIE zdarzenia aplikacji, w tym systemowe
if (event.getClass().getSimpleName().equals("OrderCreatedEvent")) {
// Źle: ręczne „routowanie" po typach wewnątrz jednej klasy
System.out.println("created"); // created
}
}
}
Tu od razu dwa problemy. Po pierwsze, handler łapie absolutnie wszystkie zdarzenia aplikacji, w tym te, o których nie myślałeś (a Spring ich niemało publikuje). Po drugie, logika określania typu zdarzenia zamienia się w ręczną „analizę przychodzących", jakbyśmy nie mieli Javy, lecz pocztę bez tematu wiadomości. Nawet jeśli zrobisz instanceof, wciąż będzie jedna klasa z rozgałęzieniami i rosnącą złożonością.
Przykład „jak należy": słuchamy konkretnego faktu przez typ
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Bean, który będzie automatycznie znaleziony przez component-scan
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Tu event jest już odpowiedniego typu: brak instanceof i rzutowań typów
System.out.println("order created: " + event.getOrderId()); // order created: ord-1
}
}
Teraz typ zdarzenia jest częścią kontraktu klasy. Czyta się to mniej więcej tak: „AuditOrderCreatedListener słucha OrderCreatedEvent". I tak, to właśnie ten poziom „samodokumentacji", który jest zazwyczaj potrzebny.
3. Dopasowywanie listenerów i zdarzeń w Springu
Chce się wierzyć, że Spring „zgaduje" twoje myśli. Ale u niego wszystko jest bardziej prozaiczne: patrzy na zarejestrowane beany, znajduje wśród nich listenery (ApplicationListener<?>), i dla każdego sprawdza: „czy ten listener pasuje do typu zdarzenia, które teraz publikujemy". Jeśli pasuje — wywołuje onApplicationEvent(...). Jeśli nie pasuje — nie rusza.
Ten obraz wygodnie trzymać w głowie w formie małego schematu. Nie jest idealny z punktu widzenia wnętrzności frameworka, ale idealnie nadaje się jako twój model mentalny:
flowchart TD
A["OrderPlacementService
publisher.publishEvent(...)"] --> B["ApplicationEventPublisher"]
B --> C["Wewnętrzna dystrybucja zdarzenia
(multicaster)"]
C --> D["AuditOrderCreatedListener
ApplicationListener⟨OrderCreatedEvent⟩"]
C --> E["NotificationOrderCreatedListener
ApplicationListener⟨OrderCreatedEvent⟩"]
C --> F["AuditOrderCancelledListener
ApplicationListener⟨OrderCancelledEvent⟩"]
Zwróć uwagę: serwis scenariusza wie tylko o ApplicationEventPublisher. Nie wie nic o audycie, nic o powiadomieniach, nic o tym, ile subskrybentów istnieje. To właśnie jest „miękkie rozsprzęgnięcie", o które się staramy.
Jeszcze jeden ważny praktyczny niuans, który często wpadza nowicjuszy: listener to zwykły bean. Oznacza to, że żeby naprawdę działał, musi być zarejestrowany w kontenerze (przez @Component/scanning lub przez @Bean) i znajdować się w strefie konfiguracji (w naszych pakietach, które są skanowane/importowane). Jeśli napisałeś klasę i zapomniałeś zrobić z niej beana — gratulacje, stworzyłeś piękny plik Java, który nigdy się nie uruchomi. To też przydatna umiejętność: odróżniać „kod" od „kodu, który naprawdę jest włączony do aplikacji".
4. Audyt dla OrderCreatedEvent
Mamy już publikację zdarzenia z serwisu scenariusza (robiliśmy to w poprzednim wykładzie). Warunkowo wygląda to tak:
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service // Serwis scenariusza: utrwala fakt i publikuje zdarzenie
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
// Publisher daje nam „wyjście" do modelu zdarzeniowego
this.publisher = publisher;
}
public void publishCreated(String orderId, String customerId) {
// Publikujemy zdarzenie dziedziny (fakt), żeby reakcje żyły osobno
publisher.publishEvent(new OrderCreatedEvent(this, orderId, customerId));
}
}
Teraz dodamy handler, który reaguje na tworzenie zamówienia i pisze audyt. Ważne: listener nie powinien sam „umieć pisać audyt do pliku", „umieć formować audit record" i tak dalej. To wszystko jest już w naszej architekturze: do tego istnieje AuditService, który już potrafi pracować z AuditWriter i profilami.
Dlatego listener to cienki łącznik: „zdarzenie → wywołanie serwisu".
import com.example.contextflow.application.service.AuditService;
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Ważne: bez rejestracji w kontenerze handler nie będzie wywoływany
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
private final AuditService auditService;
public AuditOrderCreatedListener(AuditService auditService) {
// Zależności jawnie przez konstruktor — łatwe do testowania i zrozumienia
this.auditService = auditService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Listener nie robi audytu sam — deleguje do specjalizowanego serwisu
auditService.recordCreated(event.getOrderId());
}
}
Tu dzieje się kilka „prawidłowych rzeczy" jednocześnie, i każda z nich ułatwia życie.
Po pierwsze, handler jest typebezpieczny: metoda przyjmuje OrderCreatedEvent, a nie „coś abstrakcyjnego". Po drugie, zależności widoczne przez konstruktor. Po trzecie, listener nie przechowuje stanu: po prostu reaguje. I wreszcie, najważniejsze: AuditOrderCreatedListener w ogóle nie wie, gdzie dokładnie jest pisany audyt (do konsoli, do pliku, w jakim formacie). To zmienia się przez profile i konfigurację, a listener pozostaje taki sam.
Żeby było to całkowicie naoczne, możesz zerknąć na AuditService (w uproszczonej formie), który pozostaje właścicielem audytu biznesowego:
import org.springframework.stereotype.Service;
@Service // Serwis reakcji: tu mieszka logika audytu
public class AuditService {
public void recordCreated(String orderId) {
// Tu może być zapis przez AuditWriter, integracje, formatowanie itp.
System.out.println("AUDIT: created " + orderId); // AUDIT: created ord-1
}
}
W prawdziwym projekcie zamiast println będziesz miał zapis przez AuditWriter, ale logika „serwis umie, listener wywołuje" pozostanie taka sama.
5. Dwa listenery na OrderCreatedEvent
Tu zaczyna się najfajniejsza część: gdy do jednego faktu można dodać kolejną reakcję, w ogóle nie ruszając serwisu scenariusza. W starej (bezpośredniej) modeli wróciłbyś do OrderPlacementService i dodał notificationService.sendCreated(...). Zamiast tego dodajemy nową klasę, i tyle.
Z architektonicznego punktu widzenia przypomina to prostą myśl: „Nie edytujemy historii, dodajemy subskrybenta". I to naprawdę zmniejsza powiązania.
import com.example.contextflow.application.service.NotificationDispatchService;
import com.example.contextflow.domain.events.OrderCreatedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Kolejny niezależny subskrybent tego samego faktu
public class NotificationOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {
private final NotificationDispatchService notificationService;
public NotificationOrderCreatedListener(NotificationDispatchService notificationService) {
// Serwis powiadomień — osobna odpowiedzialność, niezwiązana ze scenariuszem tworzenia zamówienia
this.notificationService = notificationService;
}
@Override
public void onApplicationEvent(OrderCreatedEvent event) {
// Reakcja jest maksymalnie cienka: zdarzenie -> wywołanie specjalizowanego serwisu
notificationService.sendCreated(event.getOrderId());
}
}
To jest ważny psychologicznie moment: zdarzenie to nie „obowiązkowa magia", to po prostu sposób, żeby twój use case nie zamienił się w „kombajn do efektów ubocznych".
Teraz dla OrderCreatedEvent mamy co najmniej dwa niezależne handlery:
- AuditOrderCreatedListener — pisze audyt biznesowy;
- NotificationOrderCreatedListener — wysyła powiadomienie.
A serwis scenariusza przy tym pozostaje mały i zrozumiały.
Żeby lepiej zobaczyć efekt, porównajmy zależność serwisu „przed" i „po" w formie prostego schematu:
flowchart TD
subgraph Before["Przed zdarzeniami"]
OPS["OrderPlacementService"] --> AS["AuditService"]
OPS --> NS["NotificationDispatchService"]
end
flowchart TD
subgraph After["Po zdarzeniach"]
OPS2["OrderPlacementService"] --> PUB["ApplicationEventPublisher"]
PUB --> L1["AuditOrderCreatedListener"]
PUB --> L2["NotificationOrderCreatedListener"]
L1 --> AS2["AuditService"]
L2 --> NS2["NotificationDispatchService"]
end
Serwis scenariusza przestał być „centralnym dyspozytorem wszystkiego". Robi to, co główne: utrwala fakt biznesowy i go ogłasza. A reakcje — przeprowadzają się do swoich punktów wejścia.
Jedyna dyscyplina, którą ważne trzymać od samego początku: nie robić tak, żeby jeden listener „zależał" od innego (np. „powiadomienie można wysłać dopiero po audycie"), bo inaczej znowu sklejasz system, tylko sprytniejszym sposobem. Jeśli naprawdę potrzebujesz ścisłej kolejności lub warunkowości — to osobny temat, podejdziemy do niego później. Dziś interesuje nas czysta modelka „kilku niezależnych reakcji".
6. Reakcje na OrderCancelledEvent
Gdy pojawia się jeden fakt (OrderCreatedEvent), prawie automatycznie pojawia się drugi: OrderCancelledEvent. I tu ważne jest, żeby nie ulec pokusie zrobienia jednego ogólnego „OrderChangedEvent" lub jednego ogólnego listenera, który reaguje „na wszystko". Im konkretniejszy fakt — tym prostsze handlery i tym mniej „rozgałęzień po kontekście".
Powiedzmy, że zdarzenie anulowania ma orderId i reason. Wtedy listenery są tak samo cienkie jak w przypadku tworzenia.
import com.example.contextflow.application.service.AuditService;
import com.example.contextflow.domain.events.OrderCancelledEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Niezależna reakcja na anulowanie: audyt
public class AuditOrderCancelledListener implements ApplicationListener<OrderCancelledEvent> {
private final AuditService auditService;
public AuditOrderCancelledListener(AuditService auditService) {
// Dependency Injection: listener sam nie tworzy serwisów
this.auditService = auditService;
}
@Override
public void onApplicationEvent(OrderCancelledEvent event) {
// Bierzemy dane ze zdarzenia i delegujemy: nie „przesłuchujemy" serwisu scenariusza
auditService.recordCancelled(event.getOrderId(), event.getReason());
}
}
I drugi listener — powiadomienie:
import com.example.contextflow.application.service.NotificationDispatchService;
import com.example.contextflow.domain.events.OrderCancelledEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
@Component // Niezależna reakcja na anulowanie: powiadomienie
public class NotificationOrderCancelledListener implements ApplicationListener<OrderCancelledEvent> {
private final NotificationDispatchService notificationService;
public NotificationOrderCancelledListener(NotificationDispatchService notificationService) {
// Kolejna niezależna zależność — bez powiązania z audytem
this.notificationService = notificationService;
}
@Override
public void onApplicationEvent(OrderCancelledEvent event) {
// Zdarzenie zawiera minimalnie potrzebne pola do reakcji
notificationService.sendCancelled(event.getOrderId(), event.getReason());
}
}
Tu łatwo zobaczyć przyjemny efekt architektoniczny: możesz dodawać dowolne reakcje na anulowanie zamówienia (np. „aktualizuj statystyki" lub „utwórz pomocniczą notatkę"), podobnie jak subskrypcję do listy mailingowej, nie ruszając serwisu anulowania zamówień.
Właśnie w tym momencie model zdarzeniowy przestaje być „teorią abstrakcyjną" i zamienia się w konkretne inżynierskie narzędzie: dodawanie funkcjonalności odbywa się przez dodanie nowego handlera, a nie przez przepisywanie głównego scenariusza.
7. Małe listenery bez monolitu
Bardzo łatwo „przenieść monolit" z serwisu scenariusza do listenera. Typowa ścieżka degradacji wygląda tak: najpierw listener wywołuje jeden serwis, potem dwa, potem pojawia się tam logika routowania, potem zapytania do repozytorium, i za tydzień masz „OrderEventsMegaListener", który znowu wymaga refaktoryzacji.
I gdy tylko listenery stają się osobnymi beanami, wyłania się kolejne praktyczne pytanie: publishEvent() po prostu rozdaje fakt „gdzieś w bok", czy naprawdę przechodzi przez tych subskrybentów w tym samym wywołaniu? Od odpowiedzi zależy latency scenariusza i zachowanie przy błędach, dlatego tego pytania lepiej nie zostawiać za kulisami.
Żeby nie przenieść monolitu do nowego miejsca, warto z góry uzgodnić rolę każdej warstwy.
Oto prosta tabela ról, która dobrze działa właśnie dla ContextFlow w ramach kursu:
| Element | Za co odpowiada | Za co nie odpowiada |
|---|---|---|
| OrderPlacementService / OrderCancellationService | Główny scenariusz: zmienić stan (stworzyć/anulować), zapisać, opublikować fakt | Nie powinien wiedzieć, jakie są reakcje i ile ich jest |
| Zdarzenie (OrderCreatedEvent, OrderCancelledEvent) | Nośnik faktu i minimalnie wystarczających danych dla reakcji | Nie powinno być „żywym" obiektem, który ktoś zmienia po publikacji |
| Listener (ApplicationListener<T>) | Reakcja na konkretny fakt: przyjąć zdarzenie i delegować pracę | Nie powinien stawać się drugim serwisem biznesowym i „repozytorium logiki" |
| Serwis reakcji (AuditService, NotificationDispatchService) | Wykonać użyteczną pracę w ramach swojej odpowiedzialności | Nie powinien zależeć od serwisu scenariusza, żeby „zapytać o szczegóły" |
Przenosząc to na praktykę, wychodzą dwie proste reguły.
Reguła pierwsza: listener powinien być tak krótki, żeby można go było przeczytać „jednym spojrzeniem", bez przewijania i bez wypalenia emocjonalnego. Jeśli twój listener stał się dłuższy niż OrderPlacementService, filozoficznie gdzieś popełniłeś błąd.
Reguła druga: listener powinien brać wszystko, czego potrzebuje, ze zdarzenia. Jeśli łapiesz się na myśli „a teraz pójdę do OrderStore, żeby dowiedzieć się jeszcze 15 pól zamówienia" — zatrzymaj się i pomyśl. Czasem jest to rzeczywiście potrzebne, ale bardzo często oznacza, że zdarzenie jest sformułowane zbyt ubogo (nie zawiera minimalnie wystarczających danych) lub handler próbuje robić za dużo.
8. Typowe błędy przy pracy z ApplicationListener<T>
Błąd nr 1: listener jest napisany, ale nie stał się beanem.
Najczęstszy scenariusz: stworzyłeś klasę, zaimplementowałeś ApplicationListener<OrderCreatedEvent>, uruchamiasz aplikację — a nie jest wywoływana. Przyczyna banalna: brak @Component (lub brak rejestracji przez @Bean), albo klasa leży poza strefą @ComponentScan/modularnej konfiguracji. W Springu „istnieje" tylko to, co zarejestrowane w kontenerze.
Błąd nr 2: jeden ogromny listener „do wszystkiego", wewnątrz którego if/else po typach zdarzeń.
Wydaje się, że tak „mniej klas". W praktyce to po prostu nowy monolit: rośnie, obrasta zależnościami, jest trudniejszy do testowania i bezpiecznej zmiany. Typebezpieczność ApplicationListener<T> właśnie po to jest wymyślona, żebyś dzielił reakcje po faktach i nie pisał ręcznego routowania.
Błąd nr 3: listener wyciąga dane nie ze zdarzenia, lecz „przesłuchuje" serwis scenariusza.
Gdy handler zaczyna zależeć od OrderPlacementService lub OrderCancellationService, faktycznie przywracasz powiązania, tylko bardziej ukryte. Listener powinien czytać dane ze zdarzenia i delegować pracę „swojemu" serwisowi, a nie pytać publishera, co w ogóle się stało.
Błąd nr 4: listener zamienia się w nowy serwis biznesowy i zawiera ciężką logikę.
Bardzo kuszące: „no skoro zdarzenie już przyszło, zróbmy tu wszystko". Ale wtedy traci się sens rozdzielenia. Listener to punkt wejścia, a nie miejsce, gdzie budujesz duży scenariusz. Jeśli reakcja jest złożona, lepiej wyciągnąć ją do osobnego serwisu i wywołać go z listenera.
Błąd nr 5: oczekiwanie, że zdarzenia to „kolejka" i wszystko wykonuje się gdzieś w tle.
To niebezpieczna iluzja: wewnątrz jednej aplikacji zdarzenia są domyślnie dostarczane jako zwykłe wywołania handlerów (czyli bez zewnętrznego brokera i bez automatycznej „asynchroniczności"). Dlatego handlery powinny być zrozumiałe i nieprzeładowane. Na razie wystarczy zachować jedną myśl: publikacja zdarzenia domyślnie pozostaje częścią zwykłego wywołania, a nie ucieka do osobnej wszechświat.
GO TO FULL VERSION