Event-Driven Architecture – czym jest i kiedy warto stosować

You are currently viewing Event-Driven Architecture – czym jest i kiedy warto stosować

Wstęp

Architektura systemów backendowych mocno się zmieniała na przestrzeni lat. Od dużych monolitów, w których każda zmiana niosła ryzyko efektu domina, po mikroserwisy, które miały ten problem rozwiązać. Sam podział aplikacji na mniejsze komponenty to jednak dopiero początek – szybko okazuje się, że kluczowym wyzwaniem staje się komunikacja między nimi. I właśnie w tym miejscu pojawia się Event-Driven Architecture (EDA).

Wyobraź sobie klasyczną aplikację e-commerce. Klient składa zamówienie, a w tle uruchamia się cała seria operacji: rezerwacja produktów w magazynie, naliczenie punktów lojalnościowych, wysyłka maila z potwierdzeniem, powiadomienie kuriera. W tradycyjnym podejściu serwis zamówień musi „wiedzieć” o wszystkich tych systemach i wywołać je po kolei. Jeśli jeden z nich nie odpowiada – cała operacja staje.

To typowy problem ścisłego powiązania między komponentami (tight coupling). Serwisy są od siebie zależne, a awaria jednego potrafi zatrzymać pozostałe. Do tego dochodzi kwestia skalowalności – synchroniczne wywołania między usługami tworzą wąskie gardła, które przy większym ruchu dają o sobie znać.

Event-Driven Architecture (EDA) rozwiązuje ten problem w inny sposób. Zamiast bezpośrednich wywołań między serwisami, komunikacja opiera się na zdarzeniach. Serwis zamówień nie woła magazynu, systemu lojalnościowego ani modułu mailowego. Po prostu publikuje informację, że zamówienie zostało złożone, i na tym kończy swoją odpowiedzialność. Pozostałe serwisy reagują na to zdarzenie niezależnie od siebie, każdy w swoim tempie.

W teorii wygląda to bardzo dobrze. Trzeba jednak wiedzieć, że EDA nie jest rozwiązaniem na wszystko. Wprowadza dodatkową infrastrukturę, zwiększa złożoność systemu i potrafi utrudnić debugowanie. Dlatego zanim sięgnie się po to podejście, warto dobrze zrozumieć, jak działa i w jakich scenariuszach faktycznie ma sens.

W tym artykule pokażę, czym jest wzorzec projektowy Event-Driven Architecture i na czym dokładnie się opiera. Chciałbym omówić jego zalety i wady oraz to, w jakich scenariuszach ma sens, a kiedy lepiej jej unikać. Na koniec przedstawię prosty przykład, żeby oprócz teorii pojawiła się też praktyka.

Czym jest Event-Driven Architecture?

Najprościej mówiąc, Event-Driven Architecture (EDA) to paradygmat tworzenia oprogramowania, w którym komunikacja między poszczególnymi modułami (lub serwisami) odbywa się poprzez produkowanie i konsumowanie zdarzeń (producers and consumers events).

W tradycyjnym podejściu (często nazywanym Request-Response), jeden system bezpośrednio pyta drugi o dane lub zleca mu zadanie. Jest to komunikacja synchroniczna. W EDA komunikacja jest asynchroniczna. Producent zdarzenia nie wie, kto (i czy w ogóle ktoś) odbierze jego komunikat. Nie interesuje go to. Jego zadaniem jest tylko poinformowanie o zmianie stanu.

To fundamentalna zmiana w myśleniu. Przestajemy myśleć imperatywnie („zrób to”, „pobierz tamto”), a zaczynamy myśleć reaktywnie („stało się to”, „zareaguj na tamto”).

Dzięki temu zyskujemy luźne powiązania (loose coupling). Serwis zamówień nie musi znać adresu IP serwisu magazynowego. On tylko wrzuca informację do wspólnego kanału komunikacyjnego. Jeśli jutro dołożysz nowy serwis analityczny, który też chce wiedzieć o zamówieniach, po prostu podpinasz go do kanału. Nie musisz zmieniać ani linijki kodu w serwisie zamówień.

Podstawowa koncepcja Event-Driven Architecture

Zdarzenia

Skoro cała architektura opiera się na zdarzeniach, warto dobrze zrozumieć, czym one właściwie są.

Zdarzenie (event) to informacja o tym, że coś się wydarzyło w systemie. Nie jest to polecenie ani prośba o wykonanie akcji. To fakt, który już zaistniał. OrderPlaced, PaymentAccepted, ProductAddedToCart – każde z nich opisuje zmianę stanu, która miała miejsce w przeszłości. Dlatego zdarzenia nazywamy zazwyczaj w czasie przeszłym.

Komenda vs. zdarzenie

Skoro „Event” jest w nazwie architektury, warto zastanowić się, czym on tak naprawdę jest. W świecie programowania często mylimy dwa pojęcia: Komenda (Command) i Zdarzenie (Event). Różnica jest subtelna, ale kluczowa dla zrozumienia Event-Driven Architecture.

  • Komenda to polecenie skierowane do konkretnego odbiorcy. Mówi „zrób coś” i oczekuje reakcji. PlaceOrder, SendEmail, ReserveProduct – to komendy. Nadawca wie, do kogo je kieruje, i zazwyczaj czeka na odpowiedź lub potwierdzenie wykonania.
  • Zdarzenie to informacja o fakcie, który już się wydarzył. Mówi „stało się coś” i nie oczekuje żadnej odpowiedzi. OrderPlaced, EmailSent, ProductReserved – to zdarzenia. Nadawca nie interesuje się tym, kto je odbierze ani czy w ogóle ktoś zareaguje. On tylko informuje system o zmianie stanu.

Komendy tworzą ścisłe powiązanie między nadawcą a odbiorcą. Zdarzenia tego powiązania nie tworzą – producent i konsument nie muszą o sobie wiedzieć.

Struktura zdarzenia

Każde zdarzenie powinno zawierać wystarczającą ilość informacji, żeby konsument mógł na nie zareagować bez dodatkowych wywołań do producenta. W praktyce oznacza to prostą, ale konsekwentną strukturę, która jasno opisuje, co się wydarzyło i kiedy.

Typowe zdarzenie składa się z kilku elementów:

  • Typ zdarzenia – jednoznaczna nazwa mówiąca, co się stało, np. OrderPlaced albo UserRegistered.
  • Timestamp – informacja o tym, kiedy zdarzenie powstało.
  • Identyfikator zdarzenia – unikalny klucz pozwalający odróżnić jedno zdarzenie od drugiego.
  • Payload – dane szczegółowe związane ze zdarzeniem, istotne z punktu widzenia konsumentów.

W kodzie takie zdarzenie najczęściej reprezentowane jest jako prosty obiekt JSON. Powinien zawierać wszystko, co jest potrzebne do zrozumienia kontekstu zdarzenia, bez konieczności dopytywania producenta o szczegóły. Oczywiście istnieją różne podejścia do tego, ile danych powinno znaleźć się w payloadzie – często spotyka się rozróżnienie na Fat Event i Thin Event – ale sam format zdarzenia pozostaje podobny.

Przykładowa struktura zdarzenia OrderPlaced:

{
  "eventId": "evt-123e4567-e89b-12d3-a456-426614174000",
  "eventType": "OrderPlaced",
  "timestamp": "2025-01-15T14:30:00Z",
  "payload": {
    "orderId": "ORD-2025-00142",
    "customerId": "CST-98765",
    "items": [
      {
        "productId": "PROD-001",
        "name": "Mechanical Keyboard",
        "quantity": 1,
        "price": 299.99
      },
      {
        "productId": "PROD-044",
        "name": "Mouse Pad",
        "quantity": 2,
        "price": 49.99
      }
    ],
    "totalAmount": 399.97,
    "shippingAddress": {
      "street": "ul. Przykładowa 15",
      "city": "Warszawa",
      "postalCode": "00-001"
    }
  }
}

Mamy tu wszystko, czego potrzebują konsumenci. Serwis magazynowy odczyta listę produktów i zarezerwuje stany. Serwis płatności zobaczy kwotę do pobrania. Moduł wysyłki dostanie adres dostawy. Każdy konsument wyciągnie z payloadu to, co go interesuje, i zignoruje resztę.

Rodzaje zdarzeń

W praktyce spotyka się różne typy zdarzeń, w zależności od tego, do czego są wykorzystywane i kto jest ich odbiorcą.

  • Zdarzenia domenowe – Opisują zmiany w logice biznesowej systemu. OrderPlaced, InvoiceIssued czy CustomerRegistered to typowe przykłady. Wynikają bezpośrednio z reguł domenowych i mają znaczenie głównie wewnątrz systemu, który je emituje.
  • Zdarzenia integracyjne – Służą do komunikacji między różnymi serwisami lub systemami. Często są uproszczoną wersją zdarzeń domenowych, dostosowaną do potrzeb zewnętrznych odbiorców. Nie zawierają szczegółów implementacyjnych, tylko dane potrzebne innym komponentom do reakcji.
  • Zdarzenia notyfikacyjne – To lekkie komunikaty informujące, że coś się zmieniło, ale bez pełnych danych. Konsument wie, że nastąpiło zdarzenie, a jeśli potrzebuje szczegółów, musi sam je pobrać. Takie podejście sprawdza się, gdy payload byłby zbyt duży albo gdy chcesz ograniczyć ilość przesyłanych danych.

Dobór odpowiedniego typu zależy od kontekstu. W systemach wewnętrznych zdarzenia domenowe zwykle sprawdzają się najlepiej. Przy integracjach z zewnętrznymi partnerami bezpieczniejszym wyborem są zdarzenia integracyjne z jasno zdefiniowanym kontraktem. Ale tak jak pisałem, koniec końców, docelowy wybór zależy od przypadku.

Jak działa przepływ zdarzeń?

Wiemy już, czym są zdarzenia. Teraz zobaczmy, jak faktycznie przepływają przez system – od momentu ich powstania aż do przetworzenia przez konsumentów.

Publish-Subscribe vs. Point-to-Point

W architekturze zdarzeniowej spotyka się dwa podstawowe modele komunikacji i warto je od siebie wyraźnie odróżnić.

  • Point-to-Point (kolejka) – To model „jeden do jednego”. Producent wysyła wiadomość do kolejki, a ta trafia do dokładnie jednego konsumenta. Jeśli kilka instancji tego samego serwisu nasłuchuje na tej samej kolejce, każda wiadomość zostanie obsłużona tylko przez jedną z nich. Ten model dobrze sprawdza się do rozłożenia obciążenia pomiędzy wiele instancji.
  • Publish-Subscribe (temat) – To model „jeden do wielu”. Producent publikuje zdarzenie do tematu, a każdy subskrybujący serwis dostaje własną kopię. Jeśli trzy różne serwisy subskrybują ten sam temat, wszystkie trzy otrzymają to samo zdarzenie. To właśnie ten model najczęściej kojarzy się z Event-Driven Architecture – jeden fakt biznesowy, wiele niezależnych reakcji.
Diagram event-driven architecture Publish-subscribe

W praktyce oba podejścia często współistnieją w jednym systemie. Zdarzenie OrderPlaced trafia do tematu, żeby poinformować różne serwisy, ale np. sam serwis magazynowy może mieć wiele instancji konkurujących o zdarzenia z jednej kolejki, żeby rozłożyć obciążenie.

Przepływ zdarzenia krok po kroku

Zobaczmy to na prostym przykładzie. Klient właśnie złożył zamówienie w sklepie internetowym.

  1. Powstanie zdarzenia – Serwis zamówień obsługuje żądanie klienta, zapisuje zamówienie w bazie danych i tworzy zdarzenie OrderPlaced. Zawiera ono wszystkie istotne informacje, takie jak identyfikator zamówienia, produkty czy dane klienta.
  2. Publikacja do brokera – Zdarzenie trafia do brokera wiadomości (np. Kafka, RabbitMQ). Broker potwierdza jego przyjęcie i od tego momentu serwis zamówień kończy swoją pracę. Nie czeka na żadne odpowiedzi.
  3. Dystrybucja do konsumentów – Broker dostarcza zdarzenie do wszystkich serwisów, które subskrybują dany temat. Każdy konsument otrzymuje własną kopię zdarzenia.
  4. Niezależne przetwarzanie – Każdy serwis reaguje na zdarzenie zgodnie ze swoją odpowiedzialnością:
    • Inventory Service rezerwuje produkty w magazynie
    • Payment Service inicjuje proces płatności
    • Notification Service wysyła maila z potwierdzeniem
    • Analytics Service zapisuje dane do celów raportowych

Wszystko dzieje się równolegle i niezależnie. Jeśli serwis analityczny ma chwilowy problem, pozostałe działają bez przeszkód. Gdy wróci do życia, przetworzy zaległe zdarzenia.

Diagram EDA przykład

Gwarancje dostarczenia

Istotnym elementem Event-Driven Architecture są gwarancje dostarczenia, jakie oferuje broker wiadomości. Najczęściej spotyka się trzy podejścia:

  • At-most-once – Zdarzenie zostanie dostarczone maksymalnie raz. Jeśli coś pójdzie nie tak, wiadomość może przepaść. Szybkie, ale ryzykowne przy krytycznych operacjach.
  • At-least-once – Zdarzenie zostanie dostarczone co najmniej raz. Konsument może otrzymać duplikat, więc musi być na to przygotowany. To najczęściej spotykany wariant w systemach produkcyjnych.
  • Exactly-once – Zdarzenie zostanie przetworzone dokładnie raz. Najtrudniejsze do osiągnięcia i zwykle okupione dodatkowymi kosztami wydajnościowymi i złożonością.

W praktyce najczęściej stosuje się at-least-once w połączeniu z idempotentnym przetwarzaniem po stronie konsumenta. Oznacza to, że konsument musi radzić sobie z powtórnym otrzymaniem tego samego zdarzenia i reagować na nie w sposób bezpieczny, bez efektów ubocznych.

Komponenty architektury Event-Driven Architecture

Wiemy już, czym są zdarzenia i jak przepływają przez system. Teraz czas przyjrzeć się trzem podstawowym komponentom, na których opiera się Event-Driven Architecture.

Producenci zdarzeń

Producent (producer) to komponent, który wykrywa zmiany w systemie i publikuje informacje o nich w postaci zdarzeń (evetns). Najczęściej jest to serwis backendowy, ale producentem może być również aplikacja mobilna czy nawet baza danych reagująca na zmiany w tabelach.

Odpowiedzialność producenta jest jasno określona. Ma on:

  • zbudować zdarzenie w ustalonej strukturze,
  • wysłać je do brokera,
  • zakończyć swoją pracę w momencie potwierdzenia przyjęcia zdarzenia.

Producent nie wie, kto odbierze zdarzenie – i nie powinien tego wiedzieć. To kluczowe dla luźnych powiązań w systemie (loose coupling).

W praktyce producent musi zadbać o kilka istotnych rzeczy:

  • spójność danych – zdarzenie powinno być publikowane dopiero po udanym zapisie do bazy,
  • odpowiednią granularność – zbyt ogólne zdarzenia ograniczają możliwości reakcji, zbyt szczegółowe zaśmiecają system,
  • wersjonowanie zdarzeń – zmiana struktury eventu nie powinna łamać istniejących konsumentów.

Brokery wiadomości

Broker to warstwa pośrednicząca między producentami a konsumentami. Przyjmuje zdarzenia, przechowuje je i dostarcza do odbiorców. Bez stabilnego i przewidywalnego brokera cała architektura EDA traci sens.

W praktyce najczęściej spotyka się dwa rozwiązania: Apache Kafka i RabbitMQ. Oba dobrze sprawdzają się w systemach opartych na zdarzeniach, ale rozwiązują nieco inne problemy.

  • Apache Kafka to rozproszona platforma do strumieniowania zdarzeń. Przechowuje dane w formie logu na dysku, co pozwala na wielokrotne odczytywanie tych samych zdarzeń. Świetnie radzi sobie z dużym wolumenem danych i sprawdza się tam, gdzie potrzebujesz trwałego przechowywania eventów oraz przetwarzania strumieniowego. Minusem jest większa złożoność konfiguracji i utrzymania.
  • RabbitMQ to klasyczny broker wiadomości oparty o protokół AMQP. Jest prostszy w konfiguracji i oferuje rozbudowane mechanizmy routingu. Dobrze sprawdza się w scenariuszach typu kolejka zadań, gdzie wiadomość po przetworzeniu znika. Przy bardzo dużych wolumenach wymaga jednak więcej uwagi przy skalowaniu.

Które rozwiązanie wybrać? To zależy od kontekstu. Jeśli potrzebujesz prostego kolejkowania i elastycznego routingu – RabbitMQ będzie dobrym wyborem. Jeśli budujesz system oparty na strumieniach zdarzeń i potrzebujesz trwałego logu – Kafka sprawdzi się lepiej. Więcej o różnicach między tymi narzędziami pisałem we wpisie o Kafce i RabbitMQ.

Konsumenci zdarzeń

Konsument (consumer) to komponent, który nasłuchuje na określone typy zdarzeń i reaguje na nie zgodnie ze swoją odpowiedzialnością. Może to być serwis realizujący logikę biznesową, moduł zapisujący dane o zdarzeniu albo komponent wysyłający powiadomienia.

Jedno zdarzenie może być przetwarzane przez wielu konsumentów, a jeden konsument może reagować na wiele typów zdarzeń. To jedna z największych zalet EDA – dodanie nowego konsumenta nie wymaga zmian po stronie producentów.

Konsument musi być jednak przygotowany na kilka realnych scenariuszy:

  • zdarzenia mogą przychodzić w nieoczekiwanej kolejności,
  • to samo zdarzenie może zostać dostarczone więcej niż raz (przy at-least-once),
  • część zdarzeń może być niepoprawna lub niemożliwa do przetworzenia.

Dlatego przetwarzanie po stronie konsumenta powinno być idempotentne, a dobra praktyka to stosowanie dead letter queue (DLQ). Zdarzenia, których nie da się obsłużyć, trafiają wtedy do osobnej kolejki, zamiast blokować cały przepływ. Dzięki temu można je później przeanalizować i zdecydować, co dalej.

Zalety i wady Event-Driven Architecture

Jak każde podejście architektoniczne, Event-Driven Architecture ma swoje plusy i minusy. Zanim zdecydujesz się na jej użycie, warto znać oba – bo EDA potrafi zarówno bardzo pomóc, jak i niepotrzebnie skomplikować system.

Zalety

  • Luźne powiązania między komponentami – Producent nie wie, kto odbierze zdarzenie, a konsument nie interesuje się tym, skąd ono dokładnie pochodzi. Dzięki temu możesz modyfikować, wymieniać albo usuwać serwisy bez wpływu na resztę systemu. Dodanie nowego serwisu analitycznego często sprowadza się do subskrypcji odpowiedniego tematu – bez zmian w istniejącym kodzie.
  • Skalowalność – Skalowanie w EDA jest naturalne. Jeśli konsument nie nadąża z przetwarzaniem, dokładana jest kolejna instancja, a broker rozkłada obciążenie. Producenci i konsumenci skalują się niezależnie – możesz mieć jednego producenta i wiele instancji przetwarzających te same zdarzenia.
  • Odporność na awarie – Awaria konsumenta nie blokuje producenta. Zdarzenia trafiają do brokera i czekają na przetworzenie. Gdy serwis wróci do życia, obsłuży zaległości. System degraduje się stopniowo, zamiast przestać działać w całości.
  • Łatwość rozbudowy systemu – Nowe funkcjonalności często oznaczają po prostu nowego konsumenta zdarzeń. Nie trzeba zmieniać istniejących serwisów ani koordynować wdrożeń między zespołami, o ile kontrakty zdarzeń są stabilne.
  • Historia i audyt – Zdarzenia naturalnie dokumentują to, co działo się w systemie. Przy brokerach z trwałym logiem, takich jak Kafka, masz dostęp do pełnej historii operacji. To bardzo pomaga przy analizie problemów i odtwarzaniu stanu systemu.

Wady i wyzwania

  • Trudniejsze debugowanie – W architekturze synchronicznej masz stack trace i jasno widać przepływ. W EDA zdarzenie przechodzi przez brokera, trafia do wielu konsumentów, a każdy z nich może emitować kolejne zdarzenia. Bez sensownego logowania i distributed tracingu szybko robi się nieczytelnie.
  • Eventual consistency – Dane w systemie zdarzeniowym nie są spójne natychmiast. Po złożeniu zamówienia magazyn może przez chwilę pokazywać stary stan. To wymaga zmiany podejścia – zarówno po stronie technicznej, jak i biznesowej. Nie każda domena dobrze toleruje eventual consistency.
  • Rozproszona logika biznesowa – W monolicie wszystko masz w jednym miejscu. W EDA logika jest rozbita na wiele serwisów reagujących na zdarzenia. Zrozumienie pełnego przepływu wymaga dobrej dokumentacji i aktualnych diagramów architektury.
  • Dodatkowa infrastruktura – EDA wymaga brokera wiadomości. To kolejny element do wdrożenia, monitorowania i utrzymania. Kafka czy RabbitMQ oznaczają dodatkowy narzut operacyjny, który w małych systemach może nie mieć uzasadnienia.
  • Obsługa błędów i duplikatów – Trzeba jasno określić, co zrobić, gdy konsument nie potrafi przetworzyć zdarzenia albo gdy to samo zdarzenie trafi do niego kilka razy. Retry, dead letter queue i mechanizmy kompensacji to dodatkowa złożoność, której nie ma w prostym wywołaniu synchronicznym.
  • Wersjonowanie zdarzeń – Struktura zdarzeń zmienia się wraz z systemem. Dodajesz pola, zmieniasz formaty, usuwasz niepotrzebne dane. Konsumenci muszą radzić sobie z różnymi wersjami zdarzeń, co wymaga przemyślanej strategii kompatybilności wstecznej.

Kiedy stosować Event-Driven Architecture?

Znamy już zalety i wady, więc czas na najważniejsze pytanie: kiedy Event-Driven Architecture faktycznie ma sens, a kiedy lepiej zostać przy prostszym podejściu.

Kiedy Event-Driven Architecture się sprawdza

  • Jedno zdarzenie, wiele niezależnych reakcji – Jeśli pojedyncza akcja biznesowa uruchamia kilka procesów w różnych częściach systemu, EDA pasuje tu naturalnie. Złożenie zamówienia może wywołać rezerwację w magazynie, płatność, wysyłkę powiadomień i zapis do analityki – każdy serwis reaguje niezależnie i we własnym tempie.
  • Integracja między zespołami lub systemami – Przy pracy wielu zespołów EDA ogranicza potrzebę ciągłej koordynacji. Jeden zespół publikuje zdarzenia zgodnie z ustalonym kontraktem, a inne zespoły subskrybują je i wykorzystują po swojemu. Wdrożenia przestają się wzajemnie blokować.
  • Systemy wymagające skalowalności – EDA dobrze radzi sobie przy zmiennym i trudnym do przewidzenia ruchu. Producenci i konsumenci skalują się niezależnie, więc w okresach wzmożonego obciążenia możesz zwiększyć liczbę instancji tylko tam, gdzie jest to potrzebne.
  • Przetwarzanie asynchroniczne i zadania w tle – Generowanie raportów, wysyłka maili czy przetwarzanie plików nie muszą blokować użytkownika. Zamiast czekać na zakończenie operacji, publikujesz zdarzenie i kończysz request. Reszta dzieje się w tle.
  • Wymagania dotyczące audytu i historii zmian – Jeśli musisz wiedzieć, co działo się w systemie i móc odtworzyć jego stan z przeszłości, zdarzenia sprawdzają się bardzo dobrze. Wzorce takie jak Event Sourcing opierają się właśnie na sekwencji zdarzeń jako źródle prawdy.
  • Architektura mikroserwisowa – Mikroserwisy i EDA dobrze się uzupełniają. Luźne powiązania, niezależne wdrożenia i autonomia zespołów to wspólne cechy obu podejść. Jeśli system już jest podzielony na mikroserwisy, EDA często jest kolejnym krokiem w jego rozwoju.

Kiedy lepiej odpuścić

  • Proste aplikacje CRUD – Jeśli aplikacja sprowadza się do zapisu danych do bazy i ich odczytu, EDA to przerost formy nad treścią. Dodatkowa infrastruktura i złożoność nie przyniosą realnych korzyści.
  • Małe zespoły bez doświadczenia w systemach rozproszonych – EDA wymaga innego sposobu myślenia. Idempotentność, duplikaty, tracing, wersjonowanie zdarzeń – tego nie da się „dorzucić po drodze”. Jeśli zespół dopiero zaczyna, lepiej najpierw opanować prostsze podejścia.
  • Scenariusze wymagające synchroniczności – Są domeny, w których operacje muszą być wykonane natychmiast i w określonej kolejności. Przelewy bankowe czy rezerwacje ostatnich zasobów często nie tolerują eventual consistency. Da się to obejść dodatkowymi wzorcami, ale kosztem większej złożoności.
  • Systemy o bardzo niskim ruchu – Przy małej liczbie użytkowników i prostym przepływie biznesowym korzyści ze skalowalności EDA są niewielkie. Koszt utrzymania brokera i całej infrastruktury pozostaje realny.
  • Projekty z napiętym harmonogramem i budżetem – EDA wymaga czasu na zaprojektowanie zdarzeń, wdrożenie infrastruktury i nauczenie zespołu nowego podejścia. Jeśli celem jest szybkie dostarczenie MVP, prostsza architektura pozwoli szybciej wejść na produkcję. Do EDA zawsze można wrócić później.
  • Brak jasno zdefiniowanych granic domenowych – EDA działa najlepiej tam, gdzie wiadomo, jakie zdarzenia mają znaczenie i kto na nie reaguje. Jeśli domena jest nieustabilizowana, łatwo skończyć z chaotycznym przepływem zdarzeń, który trudno utrzymać i rozwijać.

Przykład praktyczny – system e-commerce

Teoria teorią, ale zobaczmy, jak EDA wygląda w praktyce. Weźmy klasyczny przykład – sklep internetowy i proces składania zamówienia.

Przypadek

Klient przegląda produkty, dodaje je do koszyka i finalizuje zamówienie. W tradycyjnym podejściu serwis zamówień musiałby sekwencyjnie wywołać kilka innych serwisów: sprawdzić dostępność w magazynie, zainicjować płatność, wysłać potwierdzenie mailem, powiadomić dział wysyłki. Jeśli którykolwiek z tych kroków zawiedzie, cała operacja staje pod znakiem zapytania.

W architekturze zdarzeniowej wygląda to inaczej. Serwis zamówień robi tylko jedno – zapisuje zamówienie i publikuje zdarzenie. Resztą zajmują się niezależni konsumenci.

Przykład diagram EDA z kafka

Struktura zdarzenia

Na początek zdefiniujmy klasę reprezentującą zdarzenie OrderPlaced:

public record OrderPlacedEvent(
    String eventId,
    String eventType,
    Instant timestamp,
    OrderPayload payload
) {
    public static OrderPlacedEvent create(OrderPayload payload) {
        return new OrderPlacedEvent(
            UUID.randomUUID().toString(),
            "OrderPlaced",
            Instant.now(),
            payload
        );
    }
}

public record OrderPayload(
    String orderId,
    String customerId,
    List<OrderItem> items,
    BigDecimal totalAmount,
    ShippingAddress shippingAddress
) {}

public record OrderItem(
    String productId,
    String name,
    int quantity,
    BigDecimal price
) {}

public record ShippingAddress(
    String street,
    String city,
    String postalCode
) {}

Spring Cloud Stream – dlaczego?

Moglibyśmy komunikować się z Kafką bezpośrednio, używając KafkaTemplate i @KafkaListener. To podejście działa i w wielu projektach sprawdza się bez zarzutu. Spring Cloud Stream dodaje jednak warstwę, która w praktyce potrafi uprościć pracę z brokerami wiadomości.

Przede wszystkim dostajemy abstrakcję nad infrastrukturą. Kod biznesowy operuje na logicznych kanałach, a nie na konkretnych API Kafki czy RabbitMQ. Nie chodzi o to, żeby projektować system z myślą o łatwej zmianie brokera – to zdarza się rzadko. Chodzi raczej o to, żeby logika domenowa nie była obudowana szczegółami technicznymi. Przy podejściach takich jak DDD, Ports & Adapters czy Clean Architecture Spring Cloud Stream dobrze sprawdza się jako adapter infrastrukturalny.

Druga rzecz to mniej boilerplate’u. Serializacja (domyślnie JSON), zarządzanie grupami konsumentów, retry przy błędach czy dead letter queue są dostępne od ręki. Zamiast konfigurować każdy z tych elementów osobno, możesz skupić się na tym, co faktycznie jest istotne – logice biznesowej.

Do tego dochodzi spójna integracja z ekosystemem Springa. Jeśli używasz Spring Boota, Spring Cloud Stream naturalnie wpisuje się w istniejący sposób pracy: konfiguracja w application.yml, dependency injection, testowanie. Niczego nowego nie trzeba się uczyć.

To oczywiście nie jest jedyna słuszna droga. Jeśli zależy Ci na pełnej kontroli nad integracją z Kafką albo potrzebujesz funkcji specyficznych dla konkretnego brokera, bezpośrednie użycie KafkaTemplate może być lepszym wyborem. Spring Cloud Stream to narzędzie zwiększające wygodę – nie obowiązek.

W naszym przykładzie również skorzystamy ze Spring Cloud Stream, żeby skupić się na samej idei Event-Driven Architecture, a nie na detalach technicznych związanych z konkretnym brokerem.

Aby dodać Spring Cloud Stream z Kafką do projektu, potrzebujesz zależności:

implementation 'org.springframework.cloud:spring-cloud-stream'
implementation 'org.springframework.cloud:spring-cloud-stream-binder-kafka'

Producent – Order Service

Order Service przyjmuje zamówienie od klienta, zapisuje je w bazie danych i publikuje zdarzenie OrderPlaced. Nie wywołuje żadnych innych serwisów – jego odpowiedzialność kończy się na poinformowaniu świata o nowym zamówieniu.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final StreamBridge streamBridge;

    @Transactional
    public Order placeOrder(PlaceOrderRequest request) {
        Order order = Order.create(request);
        orderRepository.save(order);

        OrderPlacedEvent event = OrderPlacedEvent.create(
            new OrderPayload(
                order.getId(),
                order.getCustomerId(),
                mapToOrderItems(order.getItems()),
                order.getTotalAmount(),
                mapToShippingAddress(order.getShippingAddress())
            )
        );

        streamBridge.send("orders-out", event);

        return order;
    }
}

StreamBridge to komponent Spring Cloud Stream, który pozwala dynamicznie wysyłać wiadomości do kanałów. Wywołanie send("orders-out", event) publikuje zdarzenie do kanału orders-out, który w konfiguracji mapujemy na topic Kafki.

Konsumenci

Każdy serwis nasłuchuje na zdarzenia i reaguje według własnej logiki. W Spring Cloud Stream konsumenta definiujemy jako Consumer<T> – bean, który automatycznie odbiera wiadomości z przypisanego kanału.

Inventory Service – rezerwuje produkty w magazynie:

@Component
@RequiredArgsConstructor
public class InventoryEventConsumer {

    private final InventoryService inventoryService;
    private final StreamBridge streamBridge;

    @Bean
    public Consumer<OrderPlacedEvent> orderPlaced() {
        return event -> {
            try {
                inventoryService.reserveItems(
                    event.payload().orderId(),
                    event.payload().items()
                );

                streamBridge.send("inventory-out",
                    new InventoryReservedEvent(event.payload().orderId()));

            } catch (InsufficientStockException e) {
                streamBridge.send("inventory-out",
                    new InventoryReservationFailedEvent(
                        event.payload().orderId(),
                        e.getMessage()
                    ));
            }
        };
    }
}

Payment Service – inicjuje proces płatności:

@Component
@RequiredArgsConstructor
public class PaymentEventConsumer {

    private final PaymentService paymentService;
    private final StreamBridge streamBridge;

    @Bean
    public Consumer<OrderPlacedEvent> orderPlacedPayment() {
        return event -> {
            try {
                paymentService.initializePayment(
                    event.payload().orderId(),
                    event.payload().customerId(),
                    event.payload().totalAmount()
                );

                streamBridge.send("payments-out",
                    new PaymentInitializedEvent(event.payload().orderId()));

            } catch (PaymentException e) {
                streamBridge.send("payments-out",
                    new PaymentFailedEvent(
                        event.payload().orderId(),
                        e.getMessage()
                    ));
            }
        };
    }
}

Notification Service – wysyła email z potwierdzeniem:

@Component
@RequiredArgsConstructor
public class NotificationEventConsumer {

    private final EmailService emailService;
    private final CustomerRepository customerRepository;

    @Bean
    public Consumer<OrderPlacedEvent> orderPlacedNotification() {
        return event -> {
            Customer customer = customerRepository
                .findById(event.payload().customerId())
                .orElseThrow();

            emailService.sendOrderConfirmation(
                customer.getEmail(),
                event.payload().orderId(),
                event.payload().items(),
                event.payload().totalAmount()
            );
        };
    }
}

Analytics Service – zapisuje dane do celów raportowych:

@Component
@RequiredArgsConstructor
public class AnalyticsEventConsumer {

    private final AnalyticsRepository analyticsRepository;

    @Bean
    public Consumer<OrderPlacedEvent> orderPlacedAnalytics() {
        return event -> {
            OrderAnalytics analytics = OrderAnalytics.builder()
                .orderId(event.payload().orderId())
                .customerId(event.payload().customerId())
                .totalAmount(event.payload().totalAmount())
                .itemCount(event.payload().items().size())
                .timestamp(event.timestamp())
                .build();

            analyticsRepository.save(analytics);
        };
    }
}

Konfiguracja Spring Cloud Stream

W application.yml definiujemy, jak kanały logiczne mapują się na infrastrukturę Kafki:

spring:
  cloud:
    stream:
      bindings:
        # Kanał wyjściowy dla Order Service
        orders-out:
          destination: orders
        
        # Kanały wejściowe dla konsumentów
        orderPlaced-in-0:
          destination: orders
          group: inventory-service
        orderPlacedPayment-in-0:
          destination: orders
          group: payment-service
        orderPlacedNotification-in-0:
          destination: orders
          group: notification-service
        orderPlacedAnalytics-in-0:
          destination: orders
          group: analytics-service
          
      kafka:
        binder:
          brokers: localhost:9092

Kilka rzeczy wartych uwagi:

  • destination to nazwa topicu w Kafce. Wszystkie serwisy słuchają na tym samym topicu orders.
  • group to grupa konsumentów. Każdy serwis ma własną grupę, co oznacza, że każdy otrzyma własną kopię zdarzenia. Gdybyś uruchomił dwie instancje Inventory Service w tej samej grupie inventory-service, Kafka dostarczyłaby zdarzenie tylko do jednej z nich – tak działa load balancing w ramach grupy.
  • Nazwy bindingów wejściowych mają format nazwaBeana-in-0, gdzie nazwaBeana to nazwa metody zwracającej Consumer.

Co zyskujemy?

Ten sam proces w architekturze synchronicznej wymagałby, żeby Order Service znał adresy wszystkich serwisów i obsługiwał błędy z każdego z nich. Dodanie nowego kroku (np. integracji z programem lojalnościowym) oznaczałoby zmianę kodu w Order Service.

W EDA dodanie Loyalty Service to kwestia napisania nowego konsumenta i subskrypcji na topic orders. Żaden istniejący serwis nie wymaga modyfikacji. Loyalty Service po prostu nasłuchuje, nalicza punkty i publikuje LoyaltyPointsAwarded. Reszta systemu nawet nie wie, że coś się zmieniło.

Awaria Notification Service nie blokuje zamówienia – klient nie dostanie maila, ale zamówienie zostanie przetworzone. Możesz naprawić problem i ręcznie wysłać zaległe powiadomienia. W architekturze synchronicznej timeout na wysyłce maila mógłby zablokować całą transakcję.

Podsumowanie

Event-Driven Architecture to podejście, które zmienia sposób, w jaki myślimy o komunikacji między komponentami systemu. Zamiast bezpośrednich wywołań i silnych zależności, serwisy reagują na zdarzenia – asynchronicznie i bez wiedzy o sobie nawzajem.

W artykule przeszliśmy przez podstawy EDA: od tego, czym są zdarzenia i jak różnią się od komend, przez przepływ wiadomości w modelu publish-subscribe, aż po kluczowe elementy architektury – producentów, brokerów i konsumentów. Zobaczyliśmy też, jak taka architektura wygląda w praktyce na przykładzie Spring Boota z użyciem Spring Cloud Stream i Kafki.

EDA nie jest rozwiązaniem uniwersalnym. Sprawdza się tam, gdzie system musi się skalować, a poszczególne serwisy powinny pozostać możliwie niezależne i odporne na awarie. W prostych aplikacjach CRUD albo przy niewielkich zespołach często wprowadza więcej złożoności, niż realnych korzyści.

Jeśli masz własne doświadczenia z Event-Driven Architecture albo pytania po przeczytaniu artykułu, daj znać w komentarzu!

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze