Wstęp
Pracując przy systemach opartych na mikroserwisach, prędzej czy później trafisz na moment, w którym klasyczny CRUD zaczyna się dusić. Masz jedną encję, jeden model, jeden serwis – i nagle okazuje się, że wymagania na odczyt danych kompletnie nie pasują do tego, jak te dane zapisujesz. Widok na dashboardzie potrzebuje danych z pięciu tabel, a zapis to prosta aktualizacja jednego pola. Albo odwrotnie – zapis jest skomplikowanym procesem biznesowym, a odczyt to proste pobranie rekordu.
Właśnie w takich sytuacjach przydają się dwa wzorce architektoniczne: CQRS (Command Query Responsibility Segregation) i Event Sourcing. Oba pochodzą ze świata Domain-Driven Design i oba rozwiązują realne problemy, z którymi spotykasz się w średnich i dużych systemach. Często mówi się o nich razem, ale tak naprawdę to dwa osobne koncepty, które można stosować niezależnie. Możesz wdrożyć CQRS bez Event Sourcingu i odwrotnie, choć razem tworzą naprawdę spójne rozwiązanie.
W tym wpisie pokażę Ci czym dokładnie jest CQRS, czym jest Event Sourcing, jak wyglądają w kodzie Java/Spring i kiedy warto po nie sięgnąć. Omówimy też kiedy lepiej odpuścić i zostać przy klasycznym podejściu, bo te wzorce mają swoją cenę. Na końcu przejdziemy przez narzędzia dostępne w ekosystemie Java, żebyś wiedział od czego zacząć, jeśli zdecydujesz się na wdrożenie.
Czym jest CQRS?
CQRS to skrót od Command Query Responsibility Segregation, czyli rozdzielenie odpowiedzialności między komendy (zapis) a zapytania (odczyt). Brzmi skomplikowanie, ale idea jest prosta. Sam koncept wyrósł z zasady CQS (Command Query Separation) sformułowanej przez Bertranda Meyera – każda metoda powinna albo zmieniać stan, albo zwracać dane, nigdy jedno i drugie naraz. CQRS idzie o krok dalej i rozdziela te odpowiedzialności na poziomie całych modeli.
W standardowym podejściu CRUD masz jeden model danych, który służy zarówno do zapisu, jak i do odczytu. Przykładowo masz ten sam obiekt Order zapisujesz do bazy i ten sam obiekt zwracasz przez API (oczywiście DTO). Na początku to działa dobrze – masz jedną encję JPA, jedno repozytorium Spring Data i jeden serwis. Ale z czasem ten model zaczyna puchnąć. Dodajesz pola potrzebne tylko do odczytu, tworzysz coraz bardziej skomplikowane zapytania SQL, a logika walidacji miesza się z logiką prezentacji. Problem pojawia się wtedy, gdy to, co chcesz zapisać, wygląda zupełnie inaczej niż to, co chcesz odczytać.
Zobaczmy to na przykładzie sklepu internetowego. Gdy klient składa zamówienie, zapisujesz: produkty, ilości, adres dostawy, dane płatności, kody rabatowe. To komenda – zmiana stanu systemu. Ale kiedy klient wchodzi na stronę „Moje zamówienia”, chce zobaczyć uproszczoną listę: numer zamówienia, data, status, łączna kwota. Nie potrzebuje szczegółów płatności ani kodów rabatowych.
CQRS rozwiązuje ten problem przez wprowadzenie wyraźnego podziału:
- Command side – przyjmuje komendy (intencje zmiany stanu), waliduje je i wykonuje operacje zapisu. Operuje na modelu domenowym zoptymalizowanym pod logikę biznesową.
- Query side – obsługuje zapytania i zwraca dane. Operuje na modelu odczytowym (tzw. read model / projection), który jest zoptymalizowany pod konkretne widoki i zapytania.
Te dwa modele mogą korzystać z tej samej bazy danych (ale z różnych tabel/widoków) albo z zupełnie osobnych baz – np. PostgreSQL do zapisu i Elasticsearch do odczytu.

Przykład z CQRS
No dobra, teoria to jedno, ale zobaczmy jak to wygląda w kodzie. Załóżmy,że budujemy prosty system zarządzania produktami. W klasycznym podejściu miałbyś jeden ProductService z metodami do zapisu i odczytu. W CQRS rozdzielamy to na dwie strony.
Najpierw definiujemy Command, który reprezentuje intencję użytkownika:
public record CreateOrderCommand(
String customerId,
List<OrderItemRequest> items,
String shippingAddress
) {}
public record OrderItemRequest(
String productId,
int quantity
) {}
Command trafia do handlera, który zawiera logikę biznesową:
@Service
public class OrderCommandHandler {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public OrderCommandHandler(OrderRepository orderRepository,
InventoryService inventoryService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
@Transactional
public String handle(CreateOrderCommand command) {
// Walidacja biznesowa
for (OrderItemRequest item : command.items()) {
if (!inventoryService.isAvailable(item.productId(), item.quantity())) {
throw new InsufficientStockException(item.productId());
}
}
// Tworzenie agregatu domenowego
Order order = Order.create(
command.customerId(),
command.items(),
command.shippingAddress()
);
// Zapis
orderRepository.save(order);
return order.getId();
}
}
Po stronie Query mamy zupełnie inny model, zoptymalizowany pod odczyt:
public record OrderSummaryDto(
String orderId,
String customerName,
LocalDateTime orderDate,
BigDecimal totalAmount,
String status
) {}
I dedykowany serwis do odczytów:
@Service
public class OrderQueryService {
private final OrderReadRepository readRepository;
public OrderQueryService(OrderReadRepository readRepository) {
this.readRepository = readRepository;
}
public List<OrderSummaryDto> getOrdersForCustomer(String customerId) {
return readRepository.findOrderSummariesByCustomerId(customerId);
}
public OrderDetailsDto getOrderDetails(String orderId) {
return readRepository.findOrderDetailsById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}
Zwróć uwagę na kilka rzeczy. Command nie zwraca pełnego obiektu zamówienia, tylko identyfikator. Logika biznesowa jest skoncentrowana w handlerze i agregacie domenowym. Po stronie Query mamy proste DTO dopasowane do potrzeb widoku, a repozytorium może wykonywać zoptymalizowane zapytania, np. z joinami i projekcjami, bez przejmowania się strukturą modelu domenowego.
W kontrolerze Rest mamy zarówno Command jak i Query, ale widać ten podział:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderCommandHandler commandHandler;
private final OrderQueryService queryService;
public OrderController(OrderCommandHandler commandHandler,
OrderQueryService queryService) {
this.commandHandler = commandHandler;
this.queryService = queryService;
}
@PostMapping
public ResponseEntity<String> createOrder(@RequestBody CreateOrderCommand command) {
String orderId = commandHandler.handle(command);
return ResponseEntity.created(URI.create("/orders/" + orderId)).body(orderId);
}
@GetMapping("/{orderId}")
public ResponseEntity<OrderDetailsDto> getOrder(@PathVariable String orderId) {
return ResponseEntity.ok(queryService.getOrderDetails(orderId));
}
@GetMapping("/customer/{customerId}")
public ResponseEntity<List<OrderSummaryDto>> getCustomerOrders(
@PathVariable String customerId) {
return ResponseEntity.ok(queryService.getOrdersForCustomer(customerId));
}
}
Warto tu zaznaczyć, że CQRS nie wymaga dwóch osobnych baz danych. Na początek możesz mieć jedną bazę PostgreSQL z osobnymi tabelami (lub widokami) dla modelu odczytu. Dopiero gdy skala odczytów rośnie, możesz wynieść read model do osobnej bazy – np. Elasticsearch do wyszukiwania pełnotekstowego albo Redis do szybkiego cache’u. CQRS daje Ci taką opcję, ale nie wymusza jej od razu. Wiele zespołów zaczyna od jednej bazy i rozdziela dopiero wtedy, gdy widzą realne wąskie gardło.
Pewnie zastanawiasz się, czy to nie jest overengineering. Przy małej aplikacji z paroma endpointami – pewnie tak. Ale gdy masz 20 różnych widoków na te same dane, każdy z innym zestawem pól i JOINów – wtedy ten podział zaczyna się opłacać. Z mojego doświadczenia, punkt przegięcia następuje zazwyczaj wtedy, gdy Twoje zapytania SQL do odczytu stają się na tyle skomplikowane, że ich utrzymanie zaczyna kosztować więcej niż utrzymanie dwóch osobnych modeli.
Czym jest Event Sourcing?
Event Sourcing to zupełnie inne podejście do przechowywania danych niż to, do czego jesteśmy przyzwyczajeni. W klasycznym modelu zapisujesz aktualny stan obiektu. Przykładowo masz tabelę orders z kolumnami status, total_amount, updated_at – i przy każdej zmianie nadpisujesz te wartości, a poprzedni stan, który był, znika.
Event Sourcing odwraca to myślenie. Zamiast zapisywać aktualny stan obiektu, zapisujesz sekwencję zdarzeń, które do tego stanu doprowadziły. Każda zmiana w systemie to nowe zdarzenie dopisane do niezmiennego logu. Nic nie jest nadpisywane, nic nie jest usuwane.
Najlepszym tego przykładem jest historia transakcji konta bankowego. Bank nie przechowuje tylko Twojego aktualnego salda. Przechowuje każdą transakcję np. wpłata 5000 zł, przelew wychodzący 200 zł, opłata za kartę 15 zł. Twoje aktualne saldo to wynik zsumowania wszystkich tych transakcji. Gdybyś chciał wiedzieć, ile miałeś na koncie 3 miesiące temu – wystarczy odtworzyć zdarzenia (eventy) do tamtego momentu.
W Event Sourcingu źródłem prawdy jest Event Store – magazyn zdarzeń. Przechowuje on uporządkowaną sekwencję eventów dla każdego agregatu (obiektu domenowego). Eventy są niemutowalne – raz zapisane, nigdy się nie zmieniają. Nie robisz UPDATE ani DELETE na eventach. Możesz dodawać nowe eventy, które korygują poprzednie (np. OrderCancelled zamiast usuwania zamówienia), ale historia zawsze zostaje nietknięta.
To daje Ci coś, czego klasyczny CRUD nigdy nie zapewni: pełny audyt zmian, możliwość odtworzenia stanu z dowolnego momentu w przeszłości i łatwy debugging, bo widzisz dokładnie co się działo i w jakiej kolejności.
Możesz teraz w głowie mieć myśl: „No dobrze, ale jeśli agregat ma tysiące eventów, to odtworzenie stanu za każdym razem od początku będzie wolne”. I masz rację. Dlatego w Event Sourcingu stosuje się snapshoty – zapisujesz aktualny stan agregatu co N eventów (np. co 100). Przy odtwarzaniu ładujesz ostatni snapshot i odtwarzasz tylko eventy, które nastąpiły po nim. To standardowa optymalizacja, którą wspierają frameworki takie jak Axon czy Eventuate (o tym w dalszej częsci wpisu).
Event Id | Aggregate ID | Timestamp | Event Type | Payload |
1 | order-1a | 2026-02-15 10:23:45 | OrderCreated | { "orderId": "order-1a", "customerId": "user-456", "items": [ { "productId": "prod-789", "qty": 2 } ], "totalAmount": 299.99 } |
2 | order-1a | 2026-02-15 10:24:12 | OrderConfirmed | { "orderId": "order-1a", "confirmedAt": "2026-02-15T10:24:12Z" } |
3 | order-2p | 2026-02-15 10:25:03 | OrderCreated | { "orderId": "order-2p", "customerId": "user-789", "items": [ { "productId": "prod-123", "qty": 1 } ], "totalAmount": 149.99 } |
4 | order-2p | 2026-02-15 10:25:10 | OrderCancelled | { "orderId": "order-2p", "reason": "Out of stock", "cancelledAt": "2026-02-15T10:25:10Z" } |
Event Sourcing w praktyce
Zobaczmy jak wygląda Event Sourcing w kodzie. Wracamy do naszego przykładu z zamówieniami. Najpierw definiujemy eventy:
// Bazowy event zamówienia
public sealed interface OrderEvent {
UUID orderId();
}
// Zamówienie zostało utworzone
public record OrderCreated(
UUID orderId,
String customerId,
List<OrderItem> items,
BigDecimal totalAmount
) implements OrderEvent {}
// Zamówienie zostało potwierdzone
public record OrderConfirmed(
UUID orderId,
Instant confirmedAt
) implements OrderEvent {}
// Zamówienie zostało anulowane
public record OrderCancelled(
UUID orderId,
String reason,
Instant cancelledAt
) implements OrderEvent {}
Każdy event opisuje coś, co się wydarzyło – w czasie przeszłym. To ważna konwencja. Nie CreateOrder (polecenie), ale OrderCreated (fakt, który już nastąpił).
Teraz agregat Order, który odtwarza swój stan z eventów:
public class Order {
private UUID id;
private String customerId;
private List<OrderItem> items;
private BigDecimal totalAmount;
private OrderStatus status;
// Odtwarzanie stanu z historii eventów
public static Order fromEvents(List<OrderEvent> events) {
Order order = new Order();
events.forEach(order::apply);
return order;
}
private void apply(OrderEvent event) {
switch (event) {
case OrderCreated e -> {
this.id = e.orderId();
this.customerId = e.customerId();
this.items = e.items();
this.totalAmount = e.totalAmount();
this.status = OrderStatus.CREATED;
}
case OrderConfirmed e -> {
this.status = OrderStatus.CONFIRMED;
}
case OrderCancelled e -> {
this.status = OrderStatus.CANCELLED;
}
}
}
// Logika biznesowa - generuje nowy event
public OrderConfirmed confirm() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Zamówienie nie może być potwierdzone w statusie: " + status);
}
OrderConfirmed event = new OrderConfirmed(this.id, Instant.now());
this.apply(event);
return event;
}
}
Zwróć uwagę na metodę fromEvents. Agregat nie jest ładowany z pojedynczego wiersza w bazie, ale odtwarzany przez ponowne zastosowanie wszystkich zdarzeń z przeszłości.
A teraz zerknijmy na serwis, który zarządza zapisem i odczytem eventów:
@Service
@RequiredArgsConstructor
public class OrderService {
private final EventStore eventStore;
@Transactional
public void confirmOrder(UUID orderId) {
// 1. Pobierz historię eventów
List<OrderEvent> history = eventStore.getEvents(orderId);
// 2. Odtwórz aktualny stan agregatu
Order order = Order.fromEvents(history);
// 3. Wykonaj operację biznesową (generuje nowy event)
OrderConfirmed event = order.confirm();
// 4. Zapisz nowy event do Event Store
eventStore.append(orderId, event);
}
}
Zauważ ten schemat: pobierz eventy → odtwórz stan → wykonaj logikę → zapisz nowy event. To podstawowy przepływ pracy z Event Sourcingiem. Każda zmiana stanu przechodzi przez ten sam cykl.
Co daje nam takie podejście w praktyce? Jeśli na produkcji klient zgłosi, że jego zamówienie ma nieprawidłowy status, nie musisz zgadywać co się stało. Otwierasz Event Store, patrzysz na sekwencję eventów dla tego zamówienia i widzisz dokładnie co się wydarzyło: OrderCreated → OrderConfirmed → OrderCancelled → OrderCreated (klient zamówił ponownie). Cała historia jak na dłoni, bez żadnych dodatkowych logów czy tabel audytowych. To naprawdę zmienia sposób, w jaki debugujesz problemy.
CQRS i Event Sourcing – jak je połączyć?
CQRS i Event Sourcing to osobne wzorce, ale razem tworzą naprawdę spójne rozwiązanie. Dlaczego? Event Sourcing daje Ci strumień zdarzeń opisujących wszystko, co się wydarzyło w systemie. CQRS daje Ci osobny model do odczytu. Połączenie jest naturalne: z eventów budujesz projekcje (read modele), które są zoptymalizowane pod konkretne zapytania.
Przykład: masz Event Store z eventami zamówień (OrderCreated, OrderConfirmed, OrderShipped). Na ich podstawie budujesz projekcję OrderSummaryView, która zawiera zdenormalizowane dane gotowe do wyświetlenia w panelu klienta. Inna projekcja SalesReportView liczy statystyki sprzedaży. Jeszcze inna WarehouseView śledzi stany magazynowe. Wszystkie zasilane tym samym strumieniem eventów, ale każda dopasowana do innego kontekstu.
// Projekcja - nasłuchuje na eventy i buduje read model
@Component
@RequiredArgsConstructor
public class OrderSummaryProjection {
private final OrderSummaryRepository summaryRepository;
@EventHandler
public void on(OrderCreated event) {
OrderSummary summary = new OrderSummary(
event.orderId(),
event.customerId(),
event.totalAmount(),
"CREATED",
Instant.now()
);
summaryRepository.save(summary);
}
@EventHandler
public void on(OrderConfirmed event) {
summaryRepository.updateStatus(event.orderId(), "CONFIRMED");
}
}
Jak wygląda taka architektura?
Przepływ danych w połączonym CQRS + Event Sourcing wygląda następująco:
- Użytkownik wysyła komendę (np. „potwierdź zamówienie”)
- Command Handler pobiera eventy z Event Store i odtwarza agregat
- Agregat waliduje operację i generuje nowy event
- Event zostaje zapisany do Event Store
- Event jest publikowany do projekcji (asynchronicznie)
- Projekcja aktualizuje read model (np. tabelę w PostgreSQL, dokument w MongoDB)
- Zapytanie od użytkownika trafia do Query Handlera, który odpytuje read model
Między zapisem eventu (krok 4) a aktualizacją read modelu (krok 6) jest opóźnienie. To tzw. eventual consistency – spójność ostateczna. Dane w modelu odczytu mogą być przez chwilę nieaktualne. W większości systemów to opóźnienie jest na poziomie milisekund, ale musisz mieć to na uwadze przy projektowaniu UI. Np. po złożeniu zamówienia możesz pokazać komunikat „Zamówienie przyjęte” zamiast od razu przekierowywać na listę zamówień, gdzie nowy wpis może jeszcze nie być widoczny.

Kiedy stosować CQRS i Event Sourcing?
Oba wzorce rozwiązują realne problemy, ale nie każdy system ich potrzebuje. Wdrażanie ich w każdym projekcie to prosta droga do niepotrzebnej złożoności. Z drugiej strony, w odpowiednich scenariuszach potrafią rozwiązać problemy, z którymi klasyczne podejście sobie nie radzi.
Kiedy CQRS ma sens?
CQRS sprawdza się, gdy masz wyraźną asymetrię między odczytem a zapisem. Kilka sytuacji z życia:
- Odczyt i zapis mają różne modele danych. Dashboard wymaga danych z wielu agregatów, ale zapis operuje na jednym. Zamiast budować skomplikowane JOINy przy każdym zapytaniu, budujesz zdenormalizowany read model.
- Skalowalność odczytów. Masz 100x więcej odczytów niż zapisów (typowe w e-commerce, portalach informacyjnych). Z CQRS możesz skalować stronę odczytu niezależnie – np. dodać repliki bazy read-only.
- Złożona logika biznesowa po stronie zapisu. Twój Command Model implementuje skomplikowane reguły walidacji, a Query Model tego nie potrzebuje. Rozdzielenie tych odpowiedzialności upraszcza oba modele.
- Zespół pracuje niezależnie nad różnymi częściami. Jeden zespół rozwija logikę biznesową (komendy), drugi optymalizuje widoki i raporty (zapytania).
Kiedy Event Sourcing ma sens?
Event Sourcing ma sens, gdy historia zmian jest równie ważna co aktualny stan:
- Audyt i compliance. Systemy finansowe, medyczne, prawne – wszędzie gdzie musisz wiedzieć kto, co i kiedy zmienił. Event Store daje Ci to za darmo, bez dodatkowych tabel audytowych.
- Odtwarzanie stanu. Potrzebujesz wiedzieć, jak wyglądał stan systemu tydzień temu? Z Event Sourcingiem odtworzysz go, przetwarzając eventy do konkretnego momentu.
- Debugging w produkcji. Gdy coś poszło nie tak, możesz odtworzyć dokładną sekwencję zdarzeń, która doprowadziła do błędu. Z klasycznym CRUD widzisz tylko efekt końcowy.
- Systemy event-driven. Jeśli Twoja architektura i tak opiera się na zdarzeniach (Kafka, RabbitMQ), Event Sourcing wpisuje się w nią naturalnie.
Kiedy łączyć oba wzorce?
Połączenie CQRS + Event Sourcing jest najczęściej stosowane w systemach, które spełniają kilka z powyższych warunków jednocześnie. Typowe scenariusze:
- Systemy oparte na DDD z bogatą domeną biznesową
- Mikroserwisy komunikujące się przez eventy
- Systemy, w których potrzebujesz wielu różnych widoków (projekcji) na te same dane
- Platformy, w których musisz zachować pełną historię zmian i jednocześnie szybko serwować dane
Z mojego doświadczenia, jeśli pracujesz z Kafką lub RabbitMQ i masz domenę, w której historia zmian ma znaczenie biznesowe – to połączenie CQRS z Event Sourcingiem jest naturalnym kierunkiem. Natomiast pamiętaj, że sam CQRS bez Event Sourcingu to już bardzo użyteczny wzorzec. Jeśli masz skomplikowane widoki raportowe, ale historia zmian Cię nie interesuje – sam CQRS z osobnymi modelami odczytu i zapisu może rozwiązać Twój problem bez dodatkowej złożoności Event Sourcingu.
Kiedy nie stosować? Koszty i pułapki
No dobra, ale nie chcę, żebyś wyszedł z tego wpisu z wrażeniem, że CQRS i Event Sourcing to rozwiązanie na wszystko. Te wzorce mają swoją cenę i warto ją znać, zanim zaczniesz je wdrażać.
Złożoność implementacji
Pierwszy i najbardziej oczywisty koszt to zwiększona złożoność kodu. Zamiast jednego modelu masz dwa (lub więcej). Zamiast prostego CRUD-a masz commandy, handlery, zdarzenia, projekcje. Więcej klas, więcej warstw, więcej miejsc, gdzie coś może pójść nie tak.
W klasycznym podejściu zapisujesz encję i od razu możesz ją odczytać. W Event Sourcingu musisz:
- Wykonać logikę biznesową w agregacie
- Wyemitować zdarzenie
- Zapisać zdarzenie w event store
- Poczekać aż projekcja przetworzy zdarzenie
- Dopiero wtedy dane są widoczne w modelu odczytu
Każdy z tych kroków to potencjalne miejsce awarii. A debugowanie rozproszonego systemu zdarzeniowego to zupełnie inny poziom niż śledzenie prostego stack trace’a.
Eventual consistency
To chyba największa pułapka, na którą wpadają zespoły wdrażające CQRS z Event Sourcingiem. Model odczytu nie jest natychmiast spójny z modelem zapisu. Między zapisem zdarzenia a aktualizacją projekcji mija czas – może to być milisekunda, może sekunda, przy przeciążeniu systemu nawet więcej.
Co to oznacza w praktyce? Użytkownik składa zamówienie, widzi komunikat „Sukces!”, odświeża stronę… i nie widzi swojego zamówienia na liście. Bo projekcja jeszcze nie nadążyła. Albo edytuje dane profilu i przez chwilę widzi stare wartości.
Można to obejść na kilka sposobów (np. zwracać dane bezpośrednio z commanda, stosować optymistyczne UI), ale każde obejście to dodatkowa złożoność i dodatkowy kod.
Migracje i wersjonowanie zdarzeń
W Event Sourcingu zdarzenia są niezmienne – raz zapisane, zostają na zawsze. Co się dzieje, gdy musisz zmienić strukturę zdarzenia? Dodać nowe pole? Usunąć stare?
To problem wersjonowania zdarzeń, który nie istnieje w klasycznym podejściu. Możesz stosować upcasting (transformacja starych zdarzeń do nowego formatu przy odczycie), możesz trzymać wiele wersji handlerów. Każde rozwiązanie ma swoje kompromisy i wymaga przemyślanej strategii od samego początku.
Rozmiar event store
Zdarzenia tylko przyrastają – nigdy ich nie usuwasz. W systemie działającym latami event store może osiągnąć gigantyczne rozmiary. Musisz planować strategię archiwizacji, przemyśleć snapshoty, zadbać o wydajność odtwarzania agregatów z długą historią. To realne wyzwania operacyjne, które trzeba wziąć pod uwagę.
Kiedy CRUD wystarczy?
Prawda jest taka, że większość aplikacji nie potrzebuje CQRS ani Event Sourcingu. Jeśli twój system to standardowy CRUD z prostą logiką biznesową, wdrażanie tych wzorców to przerost formy nad treścią.
Zostań przy klasycznym podejściu, gdy:
- Aplikacja jest prosta – kilka encji, podstawowe operacje, brak skomplikowanej logiki domenowej
- Zespół nie ma doświadczenia – krzywa uczenia się jest stroma, a błędy kosztowne
- Nie potrzebujesz historii zmian – aktualny stan w zupełności wystarczy
- Spójność danych jest krytyczna – eventual consistency jest nieakceptowalna z powodów biznesowych lub prawnych
- Projekt ma napięty deadline – CQRS/ES wydłuża czas developmentu, szczególnie na początku
Narzędzia i biblioteki w ekosystemie Java
Jeśli zdecydujesz się na wdrożenie CQRS i Event Sourcing w projekcie Java/Spring, nie musisz pisać wszystkiego od zera. Na rynku są dojrzałe frameworki, które dostarczają gotową infrastrukturę.
Axon Framework
To zdecydowanie najbardziej dojrzałe i kompleksowe rozwiązanie dla Javy. Axon dostarcza pełną implementację CQRS i Event Sourcingu z integracją ze Springiem out of the box. Framework jest aktywnie rozwijany i ma sporą społeczność, co przekłada się na dobrą dokumentację i łatwość znalezienia pomocy przy problemach.
Co oferuje:
- Abstrakcje dla agregatów – adnotacje
@Aggregate,@CommandHandler,@EventSourcingHandlerpozwalają skupić się na logice biznesowej - Event Store – wbudowany Axon Server lub integracja z bazami SQL/NoSQL
- Saga – wsparcie dla długotrwałych procesów biznesowych rozpiętych na wiele agregatów (planuję temu tematowi poświęcić osobny wpis)
- Projekcje – mechanizm budowania modeli odczytu z adnotacją
@EventHandler
Axon ma też wersję komercyjną (Axon Server Enterprise) z dodatkowymi funkcjami jak multi-DC replication czy access control. Wersja open source w pełni wystarcza do nauki i mniejszych projektów. Jeśli zdecydujesz się na Axona, warto zacząć właśnie od Axon Server w wersji darmowej – znacznie upraszcza konfigurację i pozwala szybko zobaczyć efekty.
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private String orderId;
@CommandHandler
public OrderAggregate(CreateOrderCommand cmd) {
AggregateLifecycle.apply(new OrderCreated(cmd.orderId(), cmd.customerId()));
}
@EventSourcingHandler
public void on(OrderCreated event) {
this.orderId = event.orderId();
}
}
Axon Server możesz uruchomić jako kontener Docker, co na początek jest najszybszą opcją. Dużym plusem Axona jest dokumentacja i społeczność. Ma dość aktywne forum, sporo tutoriali i oficjalny kurs. Z minusów – Axon Server w wersji Enterprise jest płatny, a przy dużych systemach możesz natrafić na ograniczenia wersji darmowej (np. brak klastrowania).
Eventuate
Eventuate to framework, gdzie podejście jest bardziej lekkie niż Axon. Skupia się na Event Sourcingu z użyciem baz danych i message brokerów, które już masz (PostgreSQL + Kafka). Nie wymaga dedykowanego Event Store jak Axon Server.
Eventuate lepiej pasuje do systemów, które już korzystają z Kafki i chcą dodać Event Sourcing bez wprowadzania nowego komponentu infrastrukturalnego. Eventuate używa wzorca Transaction Outbox do niezawodnego publikowania eventów – zapisuje event w tej samej transakcji co zmianę w bazie, a potem osobny proces przesyła go do Kafki. To eliminuje problem „zapisałem do bazy, ale event się nie opublikował”. Z drugiej strony, Eventuate ma mniejszą społeczność i dokumentacja nie jest tak rozbudowana jak w przypadku Axona.
Którą opcję wybrać?
Osobiście, gdybym zaczynał nowy projekt i chciał wdrożyć CQRS + Event Sourcing, na start wybrałbym Axon Framework. Ma niższy próg wejścia, lepszą dokumentację i integracja ze Spring Bootem jest bezproblemowa. Axon Server w darmowej wersji wystarczy na sporo.
Gdybym natomiast miał już system oparty na Kafce i chciał dodać Event Sourcing bez nowego komponentu infrastrukturalnego – wtedy Eventuate byłby lepszym wyborem.
Warto też wspomnieć, że nie musisz używać żadnego frameworka. CQRS to wzorzec architektoniczny – możesz go zaimplementować w czystym Springu, rozdzielając handlery komend i zapytań na osobne serwisy. Event Sourcing też da się zbudować na bazie PostgreSQL i Spring Data, zapisując eventy jako wiersze w tabeli. Frameworki typu Axon czy Eventuate po prostu automatyzują powtarzalną pracę i dają gotowe rozwiązania na typowe problemy (snapshoty, wersjonowanie eventów, serializacja).
Niezależnie od tego, czy wybierzesz framework czy czyste rozwiązanie, warto najpierw zbudować mały proof of concept na boku, zanim wdrożysz to w głównym projekcie. Oba narzędzia mają dość sporo własnych konwencji i lepiej je poznać w kontrolowanym środowisku.
Podsumowanie
Mam nadzieję, że po tym wpisie CQRS i Event Sourcing przestały być dla Ciebie abstrakcyjnymi hasłami z konferencji i stały się konkretnymi narzędziami, po które wiesz kiedy sięgnąć. Oba wzorce rozwiązują realne problemy – ale mają swoją cenę w postaci złożoności. Nie wdrażaj ich „na zapas”. Sięgaj po nie wtedy, gdy klasyczne podejście zaczyna Ci ciążyć.
Jeśli chcesz pogłębić temat, polecam zacząć od oficjalnej dokumentacji Axon Framework i spróbować zbudować prosty projekt z jednym agregatem i kilkoma eventami. Zobaczysz, że po pierwszej godzinie zabawy z kodem wszystko zacznie się układać.
Daj znać w komentarzu czy pracowałeś z CQRS lub Event Sourcingiem w swoich projektach. A może masz pytania, które pomogę rozwiać? Chętnie podyskutuję.