Od dłuższego czasu słyszałem o CQRS (Command Query Responsibility Segregation) i o architekturze heksagonalnej. Z heksagonalem mam już doświadczenie – pracowałem z tym podejściem w projekcie i bardzo mi się spodobało, bo porządkuje kod i oddziela świ
Od dłuższego czasu słyszałem o CQRS (Command Query Responsibility Segregation) i o architekturze heksagonalnej. Z heksagonalem mam już doświadczenie – pracowałem z tym podejściem w projekcie i bardzo mi się spodobało, bo porządkuje kod i oddziela świat zewnętrzny od logiki biznesowej.
Z CQRS natomiast do tej pory nie miałem bezpośrednio do czynienia. Dużo o nim słyszałem, temat powracał w rozmowach rekrutacyjnych czy dyskusjach, ale nigdy nie wdrażałem go w praktyce. Dlatego postanowiłem usiąść i poukładać sobie, o co w tym wszystkim chodzi, i czy rzeczywiście te podejścia mają sens – zwłaszcza w połączeniu. I zdradzę od razu: hexagonal i CQRS pasują do siebie idealnie.
Czym jest CQRS?
CQRS to skrót od Command Query Responsibility Segregation, czyli rozdzielenie odpowiedzialności za komendy (zapis) i zapytania (odczyt). Mówiąc prościej:
- Command – inicjuje zmianę stanu systemu (np. utworzenie posta, polubienie, follow).
- Query – niczego nie zmienia, jedynie zwraca dane (np. pobranie feedu, listy obserwujących).
Kluczowe w CQRS jest świadome rozdzielenie ścieżek odpowiedzialnych za zapis i odczyt. W klasycznym CRUD często mieszamy te światy – w jednym kontrolerze obsługujemy wszystko, a pod spodem mamy jeden model i jedną bazę danych. CQRS mówi: oddzielmy to świadomie, aby niezależnie skalować i optymalizować te dwa wektory.
Dlaczego CQRS nie jest dla małych projektów
Im dłużej o tym myślałem, tym bardziej widziałem, że to podejście wymaga od programisty innego mindsetu niż klasyczny CRUD. W małym projekcie, gdzie logika jest prosta, czas developmentu ograniczony, a zespół kilkuosobowy — dokładanie dodatkowych warstw (CommandHandler
, QueryHandler
, osobne modele) tylko utrudnia życie.
W praktyce oznaczałoby to więcej klas do napisania, więcej testów do utrzymania i dodatkową barierę wejścia dla nowych osób w zespole. W efekcie, zamiast zyskać na czystości czy skalowalności, można szybko popaść w przerost formy nad treścią.
Widziałem to też w rozmowach z innymi devami — czasem pojawia się pokusa: „zróbmy CQRS, bo to brzmi nowocześnie”. Ale prawda jest taka, że w prostych systemach nie ma sensu na siłę rozdzielać odczytu od zapisu, skoro wystarczy zwykły serwis i kilka endpointów REST.
Dopiero w projektach, gdzie realnie widać asymetrię między odczytem a zapisem (np. ogromna ilość odczytów i stosunkowo mniej zapisów, albo bardzo skomplikowana logika zapisu), CQRS zaczyna pokazywać swoją wartość.
Jak wygląda CQRS w praktyce (warstwy)
Zacznijmy od prostego scenariusza. Wyobraźmy sobie system podobny do Twittera (X), gdzie musimy obsłużyć miliony odczytów feedu i setki tysięcy zapisów dziennie. W najprostszej wersji mamy klasyczne podejście:
Command (zapis)
Użytkownik publikuje tweeta → treść trafia do warstwy write → zapisujemy ją w transakcyjnej bazie danych (np. PostgreSQL). To właśnie Postgres gwarantuje spójność, integralność i możliwość rollbacku.
Query (odczyt)
W podstawowej wersji moglibyśmy także korzystać z Postgresa do odczytu.
Ale tutaj pojawia się problem skalowalności: setki tysięcy SELECT-ów dziennie szybko obciążą bazę transakcyjną.
Wprowadźmy Elasticsearch jako wyspecjalizowany model odczytu (read model). Wszystkie zapytania użytkowników realizowane są przez ES, co daje:
- natychmiastową odpowiedź dla UI,
- pełnotekstowe wyszukiwanie i filtrowanie,
- rozproszenie obciążenia na dedykowaną infrastrukturę,
- odciążenie bazy transakcyjnej.
Czemu dwie persystencje?
To naturalna konsekwencja: Postgres pełni rolę źródła prawdy — zapisujemy w nim dane i mamy gwarancję spójności, transakcyjności oraz rollbacków.
ElasticSearch pełni rolę projekcji — jest kopią zoptymalizowaną pod odczyty. Dzięki temu łączymy dwa światy:
- Postgres → spójność i bezpieczeństwo,
- ElasticSearch → szybkość i skalowalność odczytów.
Korzyści
- Postgres — gwarantuje spójność i integralność danych,
- ElasticSearch — zapewnia błyskawiczne, skalowalne odczyty,
- Asynchroniczna propagacja — oddziela write model od read modelu,
- Architektura event-driven — pozwala łatwo podłączać kolejne procesy (statystyki, raporty, powiadomienia).
Rozszerzanie obsługi zdarzeń bez bólu !
Skoro mamy już heksagonalną architekturę, pojawia się naturalna myśl: dlaczego nie wykorzystać tego podejścia szerzej?
Podczas zapisu (Command) nasz agregat może emitować TweetCreatedEvent
. Event trafia do outboxa, a następnie do brokera zdarzeń (np. Kafka). Od tego momentu otwierają się drzwi do dalszej obróbki w niezależnych komponentach:
- ElasticSearch → zasilanie feedu i szybkie odczyty,
- moduł statystyk → zliczanie aktywności użytkowników,
- moduł raportowy → agregacje do systemów BI.
Co ważne, nie musimy od razu wydzielać osobnych mikroserwisów. Na początek możemy wykorzystać proste Spring Event Listenery. Po wygenerowaniu eventu listener w tej samej aplikacji zareaguje na zdarzenie i w osobnym wątku/transakcji wykona dodatkowe operacje, np.:
- zaktualizuje tabelę statystyk,
- wygeneruje wpis do raportów,
- zapisze dane w systemie monitorującym.
Dzięki heksagonowi listener traktujemy jako kolejny adapter, który nie miesza się z logiką domenową. A jeśli w przyszłości zajdzie potrzeba skalowania — łatwo przenieść to na Kafkę czy RabbitMQ.
Gdzie tu CQRS?
To rozwiązanie wprost realizuje ideę CQRS: zapis (Command) odpowiada tylko za utrwalenie danych i emisję eventu, a odczyt (Query) realizują niezależne moduły, każdy budując własny read model dopasowany do potrzeb.
Dodatkowo event z jednej domeny może stać się Commandem w innej domenie. Na przykład moduł statystyk, który odbiera TweetCreatedEvent
, traktuje go jako wejście i uruchamia własne procesy:
- zapisuje dane w tabeli statystyk,
- emituje kolejne eventy (np.
UserStatsUpdatedEvent
), - buduje własne read modele do raportów czy paneli analitycznych.
W ten sposób każdy moduł rozwija swój własny CQRS, a cały system może rosnąć moduł po module bez naruszania logiki domenowej.
Pseudo-architektura mini-Twittera
├─ application # warstwa aplikacyjna (use-case’y)
│ ├─ command # obsługa zapisów (Command)
│ │ ├─ CreateTweetCommand.java # obiekt polecenia (DTO)
│ │ └─ CreateTweetHandler.java # handler realizujący przypadek użycia
│ │
│ └─ query # obsługa odczytów (Query)
│ ├─ GetUserTweetsQuery.java # zapytanie (DTO)
│ └─ GetUserTweetsHandler.java # handler do pobrania tweetów
│
├─ domain # serce systemu (reguły biznesowe)
│ ├─ command # logika i porty zapisu
│ │ ├─ Tweet.java # agregat Tweet (reguły, inwarianty)
│ │ ├─ TweetRepository.java # port repozytorium dla zapisu
│ │ └─ TweetCreatedEvent.java # event domenowy emitowany po zapisie
│ │
│ └─ query # modele i porty odczytu
│ ├─ TweetReadModel.java # uproszczony model do feedu/odczytu
│ └─ TweetReadRepository.java # port repozytorium dla zapytań (np. ES)
│
└─ adapters # adaptery łączące aplikację ze światem zewnętrznym
├─ command # adaptery do zapisów
│ ├─ web
│ │ └─ TweetCommandController.java # przyjmuje POST /tweets i deleguje do handlera
│ └─ persistence
│ ├─ TweetEntity.java # encja JPA odwzorowująca Tweet
│ ├─ SpringDataTweetRepository.java # repozytorium JPA (Spring Data)
│ └─ JpaTweetRepository.java # implementacja portu TweetRepository
│
├─ query # adaptery do odczytów
│ ├─ web
│ │ └─ TweetQueryController.java # obsługuje GET /tweets → handler
│ └─ readmodel
│ └─ ElasticTweetReadRepoImpl.java # adapter implementujący TweetReadRepository
│
└─ common # adaptery i komponenty współdzielone
├─ messaging
├─ OutboxPublisher.java # zapis eventów w tabeli outbox
└─ KafkaEventPublisher.java # publikacja eventów do brokera (Kafka)
Przepływ danych
- Użytkownik wysyła
POST /tweets
→ żądanie trafia do TweetCommandController. - CreateTweetHandler zapisuje dane w Postgresie.
- Po commitcie transakcji tworzony jest TweetCreatedEvent w outboxie.
- OutboxPublisher publikuje event do Kafki.
- Dedykowany proces/worker konsumuje event i aktualizuje ElasticSearch.
- Użytkownik wysyła
GET /tweets
→ TweetQueryController pobiera dane z ElasticSearch.
Podsumowanie
Coraz bardziej przekonuję się, że rozdrabnianie i świadome oddzielanie elementów (zapis vs. odczyt, porty vs. adaptery) naprawdę się opłaca. W małych systemach koszt może być zbyt wysoki, ale w dużych procesach i systemach rozproszonych ten podział daje ogromne zyski: skalowalność, przejrzystość i elastyczność. To nie jest darmowe, ale przy odpowiedniej dyscyplinie technicznej bilans wychodzi zdecydowanie na plus.
Połączenie CQRS z architekturą heksagonalną otwiera naprawdę wiele możliwości. Zyskujemy czytelny podział odpowiedzialności, elastyczność doboru technologii pod odczyt i zapis, a także kontrolę nad złożonością, która rośnie wraz z systemem. To podejście daje poczucie, że każda część systemu robi dokładnie to, do czego została zaprojektowana.
Co zyskujemy?
- Skalowalność – write i read model można rozwijać osobno, w innym tempie i przy użyciu różnych technologii.
- Czysty projekt – Command/Query oraz Ports/Adapters mają swoje wyraźne granice.
- Elastyczność – możliwość wymiany adapterów i baz bez ingerencji w domenę.
- Wydajność pod UI – read model jest szyty na miarę interfejsu użytkownika.
- Odporność architektoniczna – zdarzenia rozpraszają odpowiedzialność i otwierają drogę do dalszej integracji.
Jakie są wyzwania?
- Więcej elementów – outbox, broker zdarzeń, konsument, osobne repozytoria.
- Koszt utrzymania – większa liczba klas, kontraktów i procesów wymaga dyscypliny.
- Spójność ostateczna – trzeba zaakceptować minimalne opóźnienia między zapisem a odczytem.
- Monitoring – konieczność śledzenia całego łańcucha:
write → outbox → broker → read model
. - Skalowanie – idempotencja konsumentów, polityki retry, backpressure i partycjonowanie zdarzeń.