author-avatar
Dominik Martyniak
CQRS 15 minut

CQRS i Hexagonal czyli duet, który ma sens

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.

Krótko: Commands mutują stan, Queries go prezentują. Ten podział porządkuje architekturę, upraszcza testy i ułatwia skalowanie odczytów i zapisów w różny sposób.
 

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

  1. Użytkownik wysyła POST /tweets → żądanie trafia do TweetCommandController.
  2. CreateTweetHandler zapisuje dane w Postgresie.
  3. Po commitcie transakcji tworzony jest TweetCreatedEvent w outboxie.
  4. OutboxPublisher publikuje event do Kafki.
  5. Dedykowany proces/worker konsumuje event i aktualizuje ElasticSearch.
  6. Użytkownik wysyła GET /tweetsTweetQueryController 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ń.
2
[cqrs, hexagonal]

Więcej od Dominik Martyniak

Więcej artykułów