Command Query Responsibility Segregation czyli CQRS. Jest to wzorzec projektowy, który rozdziela zadania odczytu i zapisu do osobnych modeli. Sprawdź ten wpis, aby dowiedzieć się kiedy i jak z niego skorzystać.
CQRS, co to?
CQRS jest wzorcem projektowym autorstwa Grega Younga, który pozwala na rozdział zadań odczytu i zapisu do osobnych modeli, przy zachowaniu wysokiej spójności oraz łatwości dodawania nowych funkcjonalności. Implementując Command Query Responsibility Segragation w swoim systemie możesz uniknąć tworzenia boskich klas serwisowych, które będą posiadały w sobie wiele metod. Dzięki stosowaniu tego wzorca zwiększysz czytelność swojego kodu, a także otworzysz się na rozbudowę. Pamiętaj jednak, że niekiedy nie warto sięgać po ten wzorzec, gdyż spowoduje on niepotrzebną komplikację kodu. Jest z tym dokładnie tak, jak w przypadku innych wzorców projektowych czy architektonicznych, ponieważ w niektórych przypadkach ich stosowanie to przysłowiowe strzelanie z armaty do wróbla.

CQRS wywodzi się od koncepcji CQS. Sprawdź o co w niej chodzi.
Kiedy zastosować Command Query Responsibility Segregation?
Wzorzec ten powinniśmy stosować tylko wtedy, kiedy nasza logika jest na tyle skomplikowana, że podział na osobne modele zapisu i odczytu będzie miał więcej zalet, aniżeli wad. CQRS zawsze wprowadza dodatkowe skomplikowanie kodu. Nie warto więc CQRS-a stosować w prostych CRUD-ach, ponieważ poprzez wprowadzenie dużej ilości dodatkowych klas (osobne modele odczytu i zapisu), koncepcji command/query bus-a, jedynie zaciemnimy to, w jaki sposób nasza aplikacja działa, a i sam jej rozwój będzie utrudniony. Bogaty kod domenowy również nie zawsze wymaga użycia tego wzorca, bo może dane odczytowe nie różnią się od danych zapisowych lub logika nie jest silnie procesowa.
To, czy CQRS powinien zostać użyty, determinuje fakt czy potrzebujemy dużej autonomiczności, ponieważ w tym wzorcu poszczególne modele nie są od siebie zależne, więc różne zespoły mogą implementować funkcjonalności nie nachodząc na siebie. Dodatkowo, jeżeli zależy nam na możliwości skalowania poszczególnych zadań, lub możliwości uruchamiania ich w pełni asynchronicznie, to możemy po prostu wskazać, które modele zapisowe mają zachować się w ten sposób, a które nie. Kolejna sprawa to wydajność, bo jeżeli aplikacja ma większe obciążenie np. przy zapisie danych, aniżeli odczycie, to możemy rozdzielić naszą bazę danych na dwie inne, jedną zapisową, a drugą odczytową. Wtedy modele odczytu mogą działać na automatycznie generowanych widokach bazodanowych, w osobnej instancji np. MongoDB czy MySQL-a. I ostatnia sytuacja, to problem znacząco różniących się od siebie typów danych zapisywanych i odczytywanych, np. kiedy przy odczycie musimy dodać trzy, cztery dodatkowe kolumny, których wartości wyliczane są dynamicznie lub dociągamy dane z zewnętrznego zasobu, np. cache’u z redis-a. Warto także dodać, że CQRS doskonale nada się do systemów stosujących Event Sourcing (psst! niebawem poruszę ten temat).
CQRS wzorzec
Jak już wspomniałem, we wzorcu Command Query Responsibility Segregation chodzi o posiadanie osobnych modeli do zapisu i odczytu danych. To tak naprawdę wszystko, a sposób implementacji zależy w głównej mierze od nas. To, co prawdopodobnie słyszałeś/aś o CQRS czyli commands, queries, commad bus, query bus, to tylko otoczka, jeden z możliwych sposobów implementacji. Lecz z racji, że jest to popularny i rekomendowany sposób i na nim się dzisiaj skupimy, to zanim przejdziemy do przykładu praktycznego, to omówmy sobie jego poszczególne elementy.
- Command Bus, jest to byt, który posiada w sobie informacje na temat tego, jaki handler powinien zostać użyty do obsłużenia żądanej komendy. Możemy zaimplementować w nim mechanizm kolejkowania, a także inne przydatne funkcjonalności.
- Command, to instancja klasy, która reprezentuje wydane polecenie i przenosi wymagane dane do jego obsłużenia. Przykładowo może to być klasa ReserveMeeting(id, reservationTime, partyId).
- Command Handler, to przypisywany do konkretnej komendy w Command Bus wykonawca żądanego zadania. Wszystkie komand handler-y powinny implementować wspólny interfejs i dostarczać jedną publiczną metodę, np. handle(), która w argumencie przyjmować będzie konkretną komendę do obsłużenia. Handler zostanie wywołany przez Command Bus z odpowiednim argumentem. Metoda handle w myśl zasad naszego wzorca nie powinna nic zwracać.
- Query Bus, Query oraz Query Handler, to byty adekwatne do tych powyższych, ale realizują one zadania związane tylko z odczytem danych. Osobiście nie jestem fanem wprowadzania tych konceptów, a zamiast nich proponuję często rozwiązanie typu high level repository czy read model, czyli klasę posiadającą w sobie kilka metod służących jedynie do odczytu konkretnego typu danych.
CQRS by example
Najłatwiej CQRS-a pokazać w praktyce będzie za pomocą prawdziwego przykładu z życia wziętego. Wiedząc do czego służą poszczególne elementy tego wzorca, przejdźmy sobie krok po kroku, od wywołania czegoś na Command Bus, przez dodanie obsługiwanej komendy, aż po implementację handler-a.
Flow w naszej aplikacji zazwyczaj zaczyna się w klasie typu controller, która deleguje żądanie użytkownika do niższych warstw. Kontrolery w systemie, który korzysta z CQRS-a mogą wyglądać następująco. Metody zapisowe wywołują na Command Bus odpowiednie żądania, czyli komendy, a następnie zwracają odpowiedni response. W przypadku żądań odczytu, po prostu wywołujemy adapter dla klasy typu Read Model i zwracamy jego rezultat.
@Controller('resources/:resourceId/reservations')
class ResourceReservationsController {
constructor(
private readonly commandBus: CommandBusInterface,
private readonly reservations: ReservationsInterface
) {}
@Post()
reserve(
@Param('resourceId', ParseUUUIPipe) resourceId: string,
@Body() { startDate, endDate }: ReservationRequest,
@Logged() party: Party
): HttpResult<IdResult> {
const command = new ReserveResourceCommand(resourceId, party.id, startDate, endDate);
this.commandBus.execute(command);
return HttpResult.accepted({ id });
}
@Get(':reservationId')
@UseGuards(ResourceGuard)
async getReservation(
@Param('resourceId', ParseUUUIPipe) resourceId: string,
@Param('reservationId', ParseUUUIPipe) reservationId: string
): Promise<HttpResult<ReservationRO>> {
const reservation = await this.reservations.getOneOrFail(resourceId, reservationId);
return HttpResult.ok(
ReservationRO.create(reservation)
);
}
}
Teraz zobrazujmy sobie, jak może wyglądać model odczytu, nazwany w kontrolerze Reservations. Jak wspomniałem wcześniej, taki abstrakcyjny byt możemy zaimplementować w formie prostego portu, w postaci interfejsu, a następnie w mechanizmie DI wstrzyknąć adapter z warstwy aplikacji używający modelu infrastruktury, który pobiera dane z bazy i mapuje je do odpowiedniej postaci.
interface ReservationsInterface {
getOneOrFail(resourceId: string, reservationId: string): Promise<ReservationViewModel>;
}
class ReservationReadModel implements ReservationsInterface {
constructor(
private readonly reservationRepository: ReservationRepositoryInterface
) {}
async getOneOrFail(resourceId: string, reservationId: string): Promise<ReservationViewModel> {
const { id, partyId, reservationRange } = await this.reservationRepository.getOneOrFail(resourceId, reservationId);
ReservationViewModel.create({
id,
userId: partyId,
duration: calculateDuration(reservationRange)
});
}
}
Następnym elementem całej układanki będzie Command Bus. Byt odpowiedzialny za odpowiednie mapowanie komend do handler-ów oraz ich uruchamianie. Możemy zaimplementować go na wiele sposób. Użyć jakiegoś magicznego dependency inject-a, dekoratorów lub też manualnie inicjalizować i przechowywać obsługiwane instancje. Jedną z możliwych implementacji będzie ta najprostsza, w której command bus pozwala na rejestrowanie nowych handler-ów i uruchamianie tych istniejących. Oczywiście, do tego kodu możemy dodać inne „bajery”, jak dynamiczne wyłapywanie błędów czy obsługę asynchroniczności na żądanie.
interface CommandHandlerInterface {
handle(command: Command): void | Promise<void>;
}
class CommandBus implements CommandBusInterface {
private readonly handlers: Map<symbol, CommandHandlerInterface> = new Map();
register(command: Command, handler: CommandHandlerInterface) {
this.handlers.set(Symbol(command.name), handler);
}
execute(command: Command) {
const handler = this.handlers.get(Symbol(command.name));
if (handler) {
handler.handle(command);
}
}
}
I ostatnią częścią CQRS-a są komendy oraz klasy handle’ujące przekazane żądanie. Handler powinien implementować stosowny interfejs i w metodzie handle’ującej wykonywać zakładaną logikę. Całość może wyglądać następująco.
class ReserveResourceCommand implements Command {
constructor(
readonly resourceId: string,
readonly partyId: string,
readonly startDate: Date,
readonly endDate: Date
) {}
}
class ReserveResourceHandler implements CommandHandlerInterface {
constructor(
private readonly resourceRepository: ResourceRepository
) {}
async handle(command: ReserveResourceCommand) {
const resource = await this.resourceRepository.getOneOrFail(command.resourceId);
const reservationRange = ReservationRange.create(command.startDate, command.endDate);
resource.reserve(command.partyId, reservationRange);
await this.resourceRepository.save(resource);
}
}
CQRS vs. CQS
Wiemy już czym jest CQRS, jak i kiedy go zaimplementować. Na początku wspomniałem jednak, że wzorzec ten wywodzi się bezpośrednio od podejścia CQS czyli Command-Query Separation. Wyjaśnię teraz czym od strony praktycznej różnią się te dwa podejścia. Obydwa one mówią bowiem o rozdziale zadań zapisu i odczytu do osobnych bytów, jednak CQS odnosi się do lokalnego scope’u, np. klasy, w której podział ten zrealizujemy tak, że odczyt i modyfikacja będą usytuowane w osobnych metodach. W przypadku CQRS-a musimy patrzeć na to trochę szerzej, w skali systemu. Rozdział reguł odczytu i modyfikacji, w tym przypadku, powinien odbyć się w osobnych, dedykowanych modelach. Tak, jak zrobiliśmy to w powyższym przykładzie, wdrażając komendy do modyfikacji oraz osobny kontrakt do odczytu danych.
Podsumowanie Command Query Responsibility Segregation
- CQRS jest wzorcem projektowym.
- Stosowanie go, to w głównej mierze rozdział operacji zapisu i odczytu do osobnych modeli.
- Rozważ jego użycie tylko, jeżeli Twój kod naprawdę tego potrzebuje.
- Stosowanie CQRS-a w systemach klasy CRUD nie ma sensu.
- Command Query Responsibility Segregation to swoistego rodzaju rozwinięcie koncepcji CQS.
Dodaj komentarz