Czy zdarzyło Ci się tworzyć aplikację, w której z czasem coraz trudniej było pogodzić wymagania wydajności przy odczycie danych z potrzebami związanymi z zapisem? Większość prostych systemów na początku korzysta z podejścia CRUD (Create, Read, Update, Delete) – jednego modelu danych obsługującego zarówno odczyt, jak i modyfikacje. Jednak w miarę rozwoju aplikacji taki uniwersalny model może napotykać ograniczenia. W takich sytuacjach z pomocą przychodzi CQRS (skrót od Command Query Responsibility Segregation). Jest to wzorzec projektowy polegający na wykorzystaniu innego modelu lub komponentu do operacji modyfikacji danych (poleceń, ang. commands) niż modelu używanego do ich odczytu (zapytania, ang. queries). Taki podział pozwala niezależnie optymalizować każdą ze stron i w rezultacie może znacznie poprawić wydajność, skalowalność, a nawet bezpieczeństwo aplikacji. Nazwa CQRS nawiązuje do zasady oddzielenia poleceń od zapytań sformułowanej przez Bertranda Meyera – Command-Query Separation – która głosi, że metoda programistyczna powinna albo zmieniać stan, albo zwracać dane (nigdy oba jednocześnie). W dzisiejszym artykule wprowadzę Cię w ten koncept, omawiając na czym polega CQRS, jakie problemy rozwiązuje oraz kiedy (i czy) warto z niego skorzystać w praktyce.
Na czym polega CQRS?
Najprościej mówiąc, CQRS zakłada rozdzielenie odpowiedzialności za odczyt i zapis danych pomiędzy dwa odrębne komponenty lub warstwy systemu. Strona zapisu obsługuje polecenia (komendy) – czyli operacje zmieniające stan systemu (np. zarejestrowanie nowego użytkownika, złożenie zamówienia). Strona odczytu natomiast zajmuje się zapytaniami – pobieraniem i udostępnianiem danych, zwykle do wyświetlenia lub dalszej niezmieniającej przetwarzania (np. pobranie listy zamówień użytkownika). Co ważne, polecenia nie zwracają bezpośrednio wyników (poza ewentualnym potwierdzeniem sukcesu), a zapytania nigdy nie modyfikują stanu – dzięki temu każda ze stron może działać niezależnie i być optymalizowana pod swoje specyficzne zadania.
Dlaczego warto oddzielić odczyty od zapisów?
Stosowanie wzorca CQRS bywa pomocne, gdy napotykamy ograniczenia tradycyjnego podejścia. Oto najważniejsze korzyści z oddzielenia zapytań i poleceń:
• Niezależne skalowanie – Możliwość skalowania osobno części odczytującej i osobno zapisującej zmniejsza rywalizację o zasoby (np. blokady na bazie danych) i poprawia wydajność systemu pod dużym obciążeniem. Przykładowo, jeśli aplikacja znacznie częściej odczytuje dane niż je zapisuje, możemy wdrożyć więcej kopii komponentu odczytującego (lub repliki bazy do odczytu) bez mnożenia niepotrzebnie komponentów odpowiedzialnych za zapis.
• Optymalizacja modeli danych – Rozdział umożliwia zastosowanie innego schematu danych dla odczytów, a innego dla zapisów. Model odczytu może przechowywać dane w formacie zoptymalizowanym pod szybkie zapytania (np. zdenormalizowane widoki, cache, indeksy specjalnie pod raporty), podczas gdy model zapisu dba o integralność danych i używa struktur dogodnych dla transakcyjnych aktualizacji.
• Zwiększone bezpieczeństwo – Dzięki separacji łatwiej ograniczyć, które operacje i które moduły mają prawo modyfikować dane. Możemy dopuszczać tylko określone polecenia do zmiany stanu systemu, podczas gdy część odczytowa udostępnia wyłącznie niezmienialne widoki. Taki podział zmniejsza ryzyko nieautoryzowanej zmiany danych i zapewnia, że np. komponent prezentacji nie dokonuje niechcący żadnych modyfikacji.
• Lepsza modularność i czytelność – Rozdzielenie odpowiedzialności sprawia, że każda strona (odczytowa i zapisowa) jest prostsza i skupiona na swojej roli. Model zapisujący zajmuje się głównie logiką biznesową, walidacją i utrzymaniem spójności danych, a model odczytowy – przygotowaniem danych do wyświetlenia w najprostszej możliwej formie. Takie rozdzielenie odpowiedzialności ułatwia utrzymanie kodu i jego ewolucję. Programiści mogą łatwiej wprowadzać zmiany w logice biznesowej nie obawiając się, że skomplikują zapytania odczytu (i na odwrót).
• Prostsze zapytania – Skoro część odczytowa ma do dyspozycji własny model danych, można w niej przechowywać przygotowane wcześniej projekcje danych dostosowane do potrzeb widoków. Dzięki temu zapytania nie wymagają wykonywania kosztownych złączeń czy skomplikowanych transformacji podczas każdego wyświetlenia – dane są odczytywane bezpośrednio w postaci gotowej do użycia. To przekłada się na szybsze czasy odpowiedzi dla interfejsu użytkownika.
W praktyce powyższe zalety oznaczają bardziej skalowalną i odporną na obciążenia architekturę. Nie dziwi więc, że CQRS często pojawia się w kontekście systemów o wysokiej wydajności oraz w złożonych domenach, gdzie logika zapisu jest skomplikowana, a jednocześnie wymagane są szybkie i elastyczne raporty lub widoki danych. Warto również zauważyć, że CQRS świetnie współgra z architekturami opartymi na zdarzeniach (event-driven architecture) i wzorcem Event Sourcing – oddzielenie odczytu od zapisu ułatwia budowanie tzw. materializowanych widoków z strumienia zdarzeń systemowych. To już jednak temat na oddzielny artykuł.
CQRS w praktyce – prosty przykład
Wdrożenie CQRS nie wymaga koniecznie osobnych mikroserwisów czy baz danych – można zacząć od rozdziału logiki w ramach jednej aplikacji. Kluczowe jest wydzielenie dwóch ścieżek obsługi akcji użytkownika. Przykładowo, załóżmy że tworzymy aplikację do rezerwacji hoteli. Bez CQRS moglibyśmy mieć jeden serwis ReservationService z metodami takimi jak BookRoom() oraz GetReservationDetails(). Po wprowadzeniu CQRS rozbijemy to na dwie części: osobną komendę (polecenie) do rezerwacji oraz osobne zapytanie do pobrania danych rezerwacji. Może to wyglądać następująco:
/* Definicja polecenia (komendy) - zapis */
public class BookRoomCommand
{
public int RoomId { get; set; }
public DateTime From { get; set; }
public DateTime To { get; set; }
}
/* Handler (obsługa) dla polecenia rezerwacji */
public class BookRoomHandler
{
public void Handle(BookRoomCommand cmd)
{
/* Walidacja i logika biznesowa rezerwacji */
/* ... (np. sprawdzenie dostępności pokoju) */
/* Zapisanie rezerwacji w bazie danych */
}
}
/* Definicja zapytania - odczyt */
public class GetReservationDetailsQuery
{
public int ReservationId { get; set; }
}
/* Handler dla zapytania o szczegóły rezerwacji */
public class GetReservationDetailsHandler
{
public ReservationDto Handle(GetReservationDetailsQuery query)
{
/* Pobranie danych rezerwacji z bazy (tylko do odczytu) */
/* ... (np. z tabel złączeń lub widoku zdenormalizowanego) */
return reservationDto;
}
}Powyższy kod ilustruje koncepcyjnie, jak moglibyśmy oddzielić logikę zapisu od odczytu. BookRoomCommand wraz z obsługującym go handlerem zajmuje się dokonaniem rezerwacji (zmianą stanu systemu). Z kolei GetReservationDetailsQuery i jego handler służą wyłącznie do odczytu informacji o istniejącej rezerwacji. Zwracają one DTO z danymi, nie modyfikując niczego po drodze. W rzeczywistej aplikacji .NET można to zaimplementować np. za pomocą wzorca Mediator (biblioteka MediatR), który wysyła komendy/zapytania do odpowiednich handlerów. Ważne jest jednak nie jak to zrobimy technicznie, ale to, że utrzymujemy ścisły rozdział odpowiedzialności: jedna część systemu mutuje stan, inna tylko go odczytuje.
Kiedy warto (a kiedy nie) sięgnąć po CQRS?
Wzorzec CQRS rozwiązuje pewne problemy, ale wprowadza też dodatkową złożoność. Nie należy traktować go jako srebrnego pocisku dobrego na wszystko. Kiedy zatem CQRS ma sens? Poniżej przedstawiam kilka wskazówek:
• Złożona logika domenowa przy modyfikacjach – Jeśli pewne obszary Twojego systemu (np. moduł płatności, zarządzania zamówieniami itp.) zawierają skomplikowane reguły biznesowe przy zapisie danych, a jednocześnie wymagają udostępniania tych danych w inny, uproszczony sposób do odczytu – rozdzielenie modeli może uprościć każdy z tych aspektów osobno. W bardziej złożonych domenach użycie oddzielnych modeli dla poleceń i zapytań bywa łatwiejsze do ogarnięcia niż jeden, wspólny model próbujący robić wszystko na raz.
• Duża dysproporcja między odczytem a zapisem – Gdy aplikacja musi obsłużyć o rząd wielkości więcej odczytów niż zapisów (lub odwrotnie), CQRS pozwala skalować każdą ze stron niezależnie. Można np. uruchomić wiele instancji serwerów obsługujących zapytania (czy też utrzymywać wiele kopii bazy do odczytu), bez jednoczesnego mnożenia elementów odpowiedzialnych za transakcje zapisu. W ten sposób uzyskujemy wysoką wydajność odczytów bez kompromisów dla spójności zapisów.
• Współbieżność i konflikty przy zapisie – W środowiskach, gdzie wielu użytkowników jednocześnie modyfikuje te same dane (np. systemy współpracy, edycja dokumentów), CQRS pomaga zredukować konflikty zapisu. Dzieje się tak, ponieważ komendy mogą być zaprojektowane bardziej granularnie (precyzyjnie opisując intencję zmiany) i obsłużone kolejno, a odczyty idą z dedykowanego modelu, nie blokując operacji zapisu.
• Wymagania wysokiej wydajności i skalowalności – CQRS jest często stosowany w aplikacjach, gdzie wymagana jest ekstremalna wydajność, wysokie uptime i skalowanie (np. systemy giełdowe, platformy społecznościowe, duże systemy e-commerce). Oddzielenie odczytów od zapisów pozwala zastosować różne technologie dla obu stron – np. relacyjną bazę danych dla transakcyjnego zapisu oraz szybki magazyn dokumentowy lub in-memory cache dla odczytu. Takie elastyczne podejście jest trudne do osiągnięcia w jednorodnym modelu CRUD.
Kiedy natomiast nie warto używać CQRS? Przede wszystkim dla prostych domen i typowych aplikacji CRUD wprowadzanie CQRS może być przerostem formy nad treścią. Jeżeli Twoja aplikacja nie ma szczególnych problemów z wydajnością odczytów czy złożonością logiki, dodatkowy podział na dwie ścieżki tylko zwiększy kod do napisania i utrzymania, wprowadzając więcej miejsc potencjalnych błędów. Trzeba uważać, bo zbyt pochopne sięgnięcie po CQRS bywa "skokiem na głęboką wodę" – są przypadki, gdzie nadmierna komplikacja architektury obniża produktywność zespołu i zwiększa ryzyko niepowodzenia projektu. Dlatego zawsze należy ocenić stosunek zysków do kosztów. CQRS najlepiej sprawdza się zastosowany wycinkowo, tam gdzie jest potrzebny – np. w jednym, wybranym module systemu, który ma specyficzne wymagania (w terminologii Domain-Driven Design mówimy tutaj o ograniczonym kontekście, Bounded Context, dla którego podejmujemy decyzję o zastosowaniu CQRS). Nie ma sensu refaktoryzować całej aplikacji na CQRS, jeśli tylko jej fragment napotyka problemy wydajnościowe – zamiast tego można rozważyć wydzielenie specjalnej bazy raportowej dla cięższych zapytań lub optymalizację pojedynczych zapytań SQL. Wzorzec CQRS warto mieć w swoim architektonicznym arsenale, ale sięgać po niego rozważnie, z pełną świadomością konsekwencji.
Podsumowanie
CQRS to ciekawy i potężny wzorzec architektoniczny, który pokazuje, jak wiele można zyskać poprzez jasny podział odpowiedzialności w systemie. Oddzielenie operacji odczytu od zapisu umożliwia lepsze dostosowanie każdej z tych części do jej zadań – co przekłada się na poprawę skalowalności, wydajności i czytelności kodu w złożonych scenariuszach. Jednocześnie, jak każdy zaawansowany koncept, CQRS nie jest złotym środkiem na wszystkie problemy i wiąże się z dodatkowym nakładem pracy oraz zwiększeniem złożoności. Warto go stosować tam, gdzie tradycyjna architektura jednolitego modelu danych przestaje dawać radę.
Jeśli zaciekawiło Cię podejście CQRS i chcesz dowiedzieć się, jak zaimplementować je w praktyce w aplikacjach .NET, rozważ dołączenie do mojego szkolenia online Szkoła ASP.NET Core. W tym szkolenia zgłębiamy m.in. tematykę architektury aplikacji, w tym dokładne omówienie CQRS, Event Sourcing oraz wiele innych przydatnych wzorców i technik. Dzięki temu będziesz mógł bez obaw wykorzystać CQRS tam, gdzie przyniesie on realne korzyści, i rozwinąć swoje umiejętności projektowania nowoczesnych aplikacji.