Wprowadzenie do CQRS w NestJS
Michał Cywiński
03 mar 2023・17 min czytania
Spis treści
UserService przed refaktoryzacją
Problemy z obecnym UserService
CreateUserCommand
SendActivationEmailEventHandler
GetActiveUsersQuery
NestJS controllers
Zdefiniujmy problem
Przez wiele lat większość aplikacji budowano w oparciu o klasyczną architekturę wielowarstwową (N‑warstwową) (zob. poniżej). Jednak nawet dziś, gdy zespoły trzymają się najlepszych praktyk, takich jak stosowanie wzorców projektowych, zasad SOLID i pisanie testów jednostkowych dla serwisów, to podejście bardzo często prowadzi do nadmiernie skomplikowanych baz kodu, trudnych w utrzymaniu. Taką bazę kodu często nazywamy "Big Ball of Mud".
W tym momencie możesz zapytać: „Dlaczego ta Big Ball of Mud powstaje, skoro mój zespół i ja trzymamy się najnowszych praktyk? I dlaczego dzieje się tak w większości projektów, w których biorę udział?” Choć początkowo trzymamy się wspomnianych zasad i wydaje się, że wszystko jest pod kontrolą, z czasem warstwy kodu stają się coraz bardziej złożone. W efekcie wiele serwisów zaczyna odwoływać się do siebie nawzajem, a ich testowanie staje się uciążliwe.
W tym miejscu na scenę wkroczyły mikroserwisy. Wszyscy się nimi zachwycili, bo wyglądało na to, że znaleziono srebrną kulę na powyższe problemy. Po pewnym czasie wiele zespołów znów jednak napotkało te same trudności, tylko jeszcze trudniejsze do rozwiązania: „rozproszoną Big Ball of Mud”. Do tego doszły kwestie komunikacji i synchronizacji danych — nie wspominając o wielu jednostkach wdrożeniowych, którymi trzeba zarządzać. Prawdziwa orkiestra problemów do okiełznania, z którą zespoły mierzą się do dziś.
Jak więc uratować się z tego piekła złożoności? Czy da się mieć piękną bazę kodu, którą aż chce się testować jednostkowo?
Tak. Pozwól, że przedstawię wzorzec CQRS. Nie rozwiąże wszystkich problemów, ale w zespołach, z którymi pracowałem, CQRS oszczędził nam wielu bóli głowy.
CQRS — definicja
CQRS to akronim od Command Query Responsibility Segregation. Co to jednak w praktyce oznacza? Wzorzec CQRS zakłada użycie trzech głównych typów klas żądań do uruchamiania i orkiestracji logiki biznesowej:
Commands, które odpowiadają za modyfikację stanu danych aplikacji;
Queries, które służą wyłącznie do odczytu danych aplikacji bez zmiany jej stanu;
Events, które mogą być emitowane w trakcie wykonywania logiki biznesowej — np. aby uruchomić dodatkowe przetwarzanie danych po wykonaniu command.
Każdy z powyższych typów klas żądań ma odpowiadający mu handler, który wykonuje właściwą logikę biznesową. Traktuj handler jak pojedynczą metodę serwisu. Klasy żądań, takie jak polecenia, niosą jedynie dane wejściowe (payload) dla danego handlera.
Ważne jest też to, że commandy i query mają zawsze dokładnie jeden odpowiadający im handler. Eventy mogą mieć wiele handlerów. Przykładowo możemy powiadamiać o zdarzeniu e‑mailem i SMS‑em, co daje nam dwa różne handlery dla każdego typu powiadomienia, reagujące na to samo zdarzenie.
Każdy typ klasy żądania jest publikowany na odpowiedniej szynie: Command Bus, Query Bus lub Event Bus. Te szyny znają zarejestrowane handlery (zwykle przez kontener wstrzykiwania zależności) i wiedzą, który handler należy uruchomić z przekazaną instancją klasy żądania.
Ale co to oznacza w praktyce? Dość teorii — zobaczmy to w działaniu.
CQRS na przykładzie
W poniższym przykładzie poznamy CQRS, refaktoryzując fragment serwisu aplikacyjnego na polecenia, zapytania, zdarzenia oraz ich odpowiadające handlery.
UserService przed refaktoryzacją
Poniżej znajduje się prosta klasa UserService odpowiedzialna za tworzenie użytkownika i pobieranie aktywnych użytkowników.
Logika tworzenia odpowiada za utworzenie użytkownika z zahaszowanym hasłem oraz wysłanie e‑maila aktywacyjnego. Po utworzeniu użytkownika zwracamy ID nowo utworzonego konta.
Logika pobierania odpowiada za odfiltrowanie kont użytkowników tak, by zwrócić jedynie aktywne, a następnie zmapowanie ich do DTO.
Problemy z obecnym UserService
Zanim przejdziemy do refaktoryzacji, warto zastanowić się, jakie problemy mają takie klasy serwisowe i klasyczne podejście do serwisów:
Co ma wspólnego tworzenie użytkownika z pobieraniem aktywnych użytkowników? Poza korzystaniem z tego samego repozytorium do dostępu do danych… właściwie nic.
Jeśli chcemy pisać testy jednostkowe dla pobierania aktywnych użytkowników, musimy zamockować lub wstrzyknąć NotificationService, który nie ma nic wspólnego z getActiveUsers.
W miarę rozwoju projektu serwisy robią się coraz bardziej chaotyczne. Przy takim podejściu ostatecznie lądujemy ze spaghetti code.
Od lat słyszymy, by nie umieszczać logiki biznesowej w kontrolerach. W praktyce przenosimy bałagan z kontrolerów do serwisów. Efekt? Ten sam bałagan ląduje w serwisach zamiast w kontrolerach — i wciąż nie zyskujemy nic w kwestii łatwości utrzymania bazy kodu.
Mając to na uwadze, czas na refaktoryzację. Skorzystamy z doskonałego pakietu @nestjs/cqrs dla NestJS, który ma też świetną dokumentację.
CreateUserCommand
Wprowadzenie naszego pierwszego commandu i handlera jest tak proste, jak zadeklarowanie dwóch odpowiadających sobie klas: CreateUserCommand oraz CreateUserCommandHandler (druga musi mieć dekorator @CommandHandler i implementować interfejs ICommandHandler).
W commandzie przechowujemy informacje potrzebne do utworzenia konta użytkownika i modyfikacji stanu aplikacji: adres e‑mail i hasło. Nasz handler zawiera logikę wyekstrahowaną z oryginalnej klasy UserService.
Warto zauważyć, że wstrzykujemy nową zależność: EventBus z @nestjs/cqrs. Służy do wyemitowania zdarzenia informującego, że konto użytkownika zostało utworzone. Handlery zdarzeń (omówione później) mogą niezależnie przechwycić takie zdarzenia i na nie zareagować: w naszym przypadku wysłać e‑mail aktywacyjny z tajnym tokenem.
Co naprawdę cieszy, to fakt, że testy jednostkowe stają się banalnie proste w porównaniu z poprzednim podejściem. Wystarczy sprawdzić, czy użytkownik został zapisany w bazie z poprawnymi właściwościami oraz czy zdarzenie zostało wyemitowane (co można zweryfikować przy pomocy mocka EventBus).
Nie ma więc znaczenia, ile handlerów zareaguje na opublikowane zdarzenie — każdy z nich testujemy niezależnie. Daje nam to większą swobodę, bo nie polegamy na serwisach takich jak NotificationService, który był wstrzykiwany w początkowym UserService. Dzięki temu logika wyzwalana przy rejestracji użytkownika zostaje odseparowana od samego utworzenia encji użytkownika. Fajne, prawda?
SendActivationEmailEventHandler
Nazwa mówi sama za siebie. To nasz handler zdarzenia, który reaguje na UserCreatedEvent wyemitowane w CreateUserCommandHandler i wysyła e‑mail aktywacyjny na podstawie payloadu przekazanego i zbudowanego w klasie zdarzenia w tym handlerze commanda.
Podobnie jak w przypadku handlerów commandów, musimy udekorować go dekoratorem @EventsHandler i zaimplementować interfejs IEventHandler z @nestjs/cqrs.
To tutaj wstrzykujemy serwis notyfikacji. Zauważ, że możemy teraz testować jednostkowo samo wysyłanie e‑maila aktywacyjnego bez faktycznego tworzenia konta użytkownika. Nasz kod jest znacznie bardziej modułowy.
Co więcej, łatwo wyobrazić sobie kolejny handler zdarzenia reagujący na to samo zdarzenie w sposób niezależny — np. wysyłając SMS. Taki handler również można testować niezależnie od innych handlerów zdarzeń i od handlera commanda, który je wyemitował. Im bardziej zdarzeniowa staje się nasza aplikacja, tym więcej zyskujemy na takiej architekturze.
GetActiveUsersQuery
Na koniec wprowadzimy zrefaktoryzowane GetActiveUsersQuery oraz GetActiveUsersQueryHandler, udekorowane @QueryHandler i implementujące interfejs IQueryHandler.
Klasa query niesie wszystkie niezbędne informacje (payload) do wykonania logiki zdefiniowanej w odpowiadającym handlerze. W naszym przypadku jest to filtr właściwości isActive. Po wykonaniu filtracji wyniki są mapowane do DTO dokładnie tak samo, jak w początkowym UserService.
NestJS controllers
Mając przygotowane wszystkie query i commandy, możemy wstrzyknąć QueryBus i CommandBus do kontrolera API. Handlery zostaną automatycznie dopasowane i wywołane. Pozbywamy się wszelkich zależności (serwisów) poza busami. Czysto, prawda?
Wprowadzanie CQRS do istniejących baz kodu
Ktoś może powiedzieć: „OK, super. Fajnie zobaczyć ten wzorzec, ale pracuję nad starą bazą kodu (legacy). Nie mogę teraz wprowadzić tego do mojego projektu”. To nie jest argument. Możesz wprowadzić ten wzorzec do istniejącej bazy kodu. Czasem łatwo, czasem z dodatkowym wysiłkiem. Przerabiałem to w kodach klasy enterprise — i zawsze było warto.
Nie musisz wdrażać tego wzorca od razu w całości. Właśnie tu CQRS błyszczy: można go wprowadzać stopniowo, bez wywracania projektu do góry nogami. Nowe funkcjonalności w większości da się od razu implementować jako handlery commandów i query. Tak, natychmiastowa korzyść.
Oczywiście czasem będzie to wymagało wstrzyknięcia skomplikowanych serwisów (czego w idealnym scenariuszu wolelibyśmy unikać), ale wciąż możesz je mockować, by ułatwić sobie testy jednostkowe — i życie.
W przypadku istniejących serwisów możesz zacząć refaktoryzację od wyodrębniania handlerów, gdy tylko ruszysz metodę serwisu związaną z Twoim aktualnym tikietem w Jira/Trello/twoim ulubionym narzędziu do śledzenia zadań. Zacznij po prostu od przeniesienia logiki do nowego handlera bez modyfikacji, aby nie mieszać nowej funkcjonalności ze sprzątaniem. Jak zawsze pamiętaj o dodaniu testów jednostkowych, jeśli wcześniej ich nie było.
Utwórz pull request, upewnij się, że wszyscy rozumieją zrefaktoryzowany kod, i zmerguj go do gałęzi deweloperskiej. Teraz czas na właściwe zadanie. Powinno być znacznie łatwiej dodać nową funkcję, bo operujesz wyłącznie na poziomie handlerów CQRS.
Tak, do tego właśnie dążyliśmy: ultraproste klasy, wyspecjalizowane w robieniu dokładnie jednej rzeczy i używające tylko tych zależności, których naprawdę potrzebują.
Final thoughts
CQRS może wymagać dopisania nieco dodatkowego kodu niosącego payload dla naszych handlerów, ale w zamian niezwykle ułatwia tworzenie wielu małych, łatwych w utrzymaniu klas, które świetnie się testuje jednostkowo.
Czy poleciłbym go do każdego projektu? Prawdopodobnie nie. Jeśli potrzebujesz jedynie kilku prostych CRUD‑ów i zależy Ci na szybkim dowożeniu, być może lepiej trzymać się starych, dobrych klas serwisów. Warto jednak rozważyć refaktoryzację starego kodu, gdy tylko zmieni się zakres projektu albo wzrośnie jego złożoność.
W moim developerskim niezbędniku dla projektów średnich i dużych CQRS to zawsze właściwa droga. To się zwróci — obiecuję.
Digital Transformation Strategy for Siemens Finance
Cloud-based platform for Siemens Financial Services in Poland


Może Ci się również spodobać...

Jak wybrać najlepszą agencję Node.js w 2023 roku: kompleksowy przewodnik
Node.js stał się kluczową technologią do tworzenia niezawodnych, skalowalnych aplikacji webowych. W tym kontekście rola firm programistycznych specjalizujących się w Node.js jest ważniejsza niż kiedykolwiek. Ten artykuł pomoże Ci wybrać najlepsze firmy specjalizujące się w Node.js, dopasowane do Twoich potrzeb.
Marek Pałys
02 lut 2023・6 min czytania

Jak zainstalować Ruby i Ruby on Rails oraz używać RubyGems
Poznaj przewodnik krok po kroku, który pokaże, jak zainstalować i wykorzystać Ruby on Rails do efektywnego tworzenia aplikacji internetowych. Dowiesz się, jak zainstalować i skonfigurować Ruby, zarządzać różnymi wersjami, korzystać z RubyGems i Bundlera oraz stworzyć nowy projekt w Ruby on Rails. Rozpocznij swoją przygodę z tworzeniem aplikacji w Ruby on Rails dzięki temu kompleksowemu przewodnikowi.
Jan Grela
20 mar 2020・6 min czytania

Stack technologiczny 2020: GraphQL, Apollo Server i React.js
Od 2000 roku zasady RESTful są branżowym standardem w tworzeniu web API, jednak GraphQL oferuje bardziej efektywne rozwiązanie. W tym artykule omawiamy zalety GraphQL w porównaniu z REST, jego popularność oraz to, jak za pomocą Apollo GraphQL zbudować serwer i aplikację kliencką.
Wojciech Cichoradzki
12 maj 2020・7 min czytania
Gotowy, aby scentralizować swoje know-how z pomocą AI?
Rozpocznij nowy rozdział w zarządzaniu wiedzą — gdzie Asystent AI staje się centralnym filarem Twojego cyfrowego wsparcia.
Umów bezpłatną konsultacjęPracuj z zespołem, któremu ufają firmy z czołówki rynku.




