Gdy jeden serwis zaczyna jednocześnie przyjmować zamówienia, zmieniać ich statusy, generować raporty i obsługiwać szybkie odczyty, klasyczny CRUD szybko robi się trudny do utrzymania. NestJS CQRS pomaga rozdzielić logikę zapisu od logiki odczytu, ale tylko wtedy, gdy zastosujemy ten wzorzec świadomie. Pokażę, jak skonfigurować moduł, tworzyć komendy i zapytania, obsługiwać zdarzenia oraz uniknąć typowych problemów z transakcjami i spójnością danych.
Rozdzielenie zapisu i odczytu upraszcza rozwój dużych aplikacji NestJS
- Komendy zmieniają stan aplikacji, a zapytania tylko odczytują dane.
- Pakiet @nestjs/cqrs udostępnia CommandBus, QueryBus, EventBus oraz dekoratory handlerów.
- CQRS najlepiej sprawdza się przy złożonej logice biznesowej, wielu modelach odczytu i niezależnym skalowaniu.
- Wzorzec nie oznacza automatycznie event sourcingu ani mikroserwisów.
- Największe ryzyka to opóźniona synchronizacja read modelu, trudniejsze transakcje i nadmiar klas.

Na czym polega CQRS i kiedy ma sens w aplikacji webowej
CQRS, czyli Command Query Responsibility Segregation, rozdziela operacje zmieniające dane od operacji, które je odczytują. Komenda może utworzyć zamówienie albo anulować płatność, natomiast zapytanie pobiera listę zamówień lub szczegóły klienta. Jedna ścieżka zapisuje, druga czyta, dzięki czemu każda z nich może mieć własne reguły, modele i optymalizacje.
W prostym systemie nie ma potrzeby komplikować architektury. Klasyczny serwis z metodami create, update i find będzie czytelniejszy, tańszy w utrzymaniu i łatwiejszy do testowania. Sam zaczynam od prostego CRUD-u i sięgam po CQRS dopiero wtedy, gdy mieszanie odczytów i zapisów zaczyna realnie utrudniać pracę.
| Klasyczny CRUD | CQRS |
|---|---|
| Jeden model często obsługuje zapis i odczyt. | Komendy i zapytania mają osobne modele oraz handlery. |
| Dobrze pasuje do małych i średnich modułów. | Lepiej radzi sobie ze złożonymi procesami biznesowymi. |
| Odczyt i zapis zwykle korzystają z tej samej ścieżki danych. | Można niezależnie optymalizować bazę zapisu i read model. |
| Mniej klas i mniejszy narzut organizacyjny. | Więcej elementów, ale wyraźniejszy podział odpowiedzialności. |
W aplikacji webowej CQRS jest szczególnie przydatny, gdy zapis wymaga kilku walidacji, wywołuje procesy asynchroniczne albo publikuje zdarzenia dla innych modułów. Przykładem może być sklep, w którym utworzenie zamówienia rezerwuje produkt, nalicza rabat, uruchamia płatność i wysyła powiadomienie. Taki proces trudno utrzymać w jednym uniwersalnym serwisie bez wyraźnych granic.
Jak skonfigurować moduł CQRS w NestJS
Podstawą jest pakiet @nestjs/cqrs. Instalacja zajmuje chwilę, ale sama obecność biblioteki niczego jeszcze nie zmienia w aplikacji.
npm install @nestjs/cqrsModuł trzeba dodać do głównego modułu aplikacji. W aktualnym podejściu używam CqrsModule.forRoot(), ponieważ pozwala ono skonfigurować publisherów i obsługę wyjątków, gdy aplikacja zaczyna mieć większe wymagania.
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [
CqrsModule.forRoot(),
OrdersModule,
],
})
export class AppModule {}Na początku wystarczą domyślne ustawienia. Nie tworzę własnych publisherów tylko dlatego, że biblioteka na to pozwala. Taka konfiguracja ma sens dopiero wtedy, gdy potrzebujemy własnego mechanizmu dystrybucji zdarzeń, monitorowania wyjątków albo integracji z zewnętrznym brokerem.
Przejrzysta struktura katalogów ogranicza chaos. Dla modułu zamówień może wyglądać tak:
orders/
application/
commands/
create-order.command.ts
create-order.handler.ts
queries/
get-order.query.ts
get-order.handler.ts
domain/
events/
order-created.event.ts
infrastructure/
orders.repository.ts
orders.controller.ts
orders.module.tsNie trzeba kopiować tej struktury jeden do jednego. Istotne jest, aby kod odpowiedzialny za wykonanie komendy nie był przypadkowo wymieszany z kontrolerem, encją ORM i zapytaniami do raportów. Granice odpowiedzialności są ważniejsze niż sama liczba katalogów.
Jak utworzyć komendę i handler dla operacji zapisu
Komenda opisuje zamiar, a nie techniczny sposób wykonania operacji. CreateOrderCommand nie powinno wiedzieć, czy dane trafią do PostgreSQL, MongoDB czy zewnętrznego API. Przechowuje tylko informacje potrzebne do wykonania żądania.
export class CreateOrderCommand {
constructor(
public readonly customerId: string,
public readonly productId: string,
public readonly quantity: number,
) {}
}Handler zawiera właściwą logikę. Oznaczamy go dekoratorem @CommandHandler() i implementujemy interfejs ICommandHandler. W prawdziwej aplikacji repozytorium powinno być osobnym portem lub serwisem infrastruktury, ale dla czytelności przykład pokazuje najważniejszy przepływ.
import {
CommandHandler,
EventBus,
ICommandHandler,
} from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';
import { OrderCreatedEvent } from '../../domain/events/order-created.event';
import { OrdersRepository } from '../../infrastructure/orders.repository';
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler
implements ICommandHandler
{
constructor(
private readonly repository: OrdersRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateOrderCommand): Promise {
if (command.quantity < 1) {
throw new Error('Ilość produktu musi być większa od zera');
}
const order = await this.repository.create({
customerId: command.customerId,
productId: command.productId,
quantity: command.quantity,
});
this.eventBus.publish(
new OrderCreatedEvent(order.id, order.customerId),
);
return order.id;
}
} Z kontrolera uruchamiamy komendę przez CommandBus. Kontroler pozostaje cienki i nie zna szczegółów walidacji, zapisu ani publikowania zdarzeń.
import { Body, Controller, Post } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { CreateOrderCommand } from './application/commands/create-order.command';
@Controller('orders')
export class OrdersController {
constructor(private readonly commandBus: CommandBus) {}
@Post()
create(@Body() body: {
customerId: string;
productId: string;
quantity: number;
}) {
return this.commandBus.execute(
new CreateOrderCommand(
body.customerId,
body.productId,
body.quantity,
),
);
}
}W handlerze umieszczam reguły biznesowe, ale nie wszystkie możliwe mechanizmy aplikacji. Walidacja formatu DTO może zostać w warstwie HTTP, natomiast sprawdzenie, czy klient może złożyć dane zamówienie, należy już do logiki biznesowej. To rozróżnienie zapobiega sytuacji, w której reguły działają tylko dla jednego endpointu.
Przeczytaj również: Bootstrap spinner - jak wdrożyć ładowanie i dostępność
Zdarzenia nie są tym samym co komendy
Komenda mówi aplikacji, co ma zrobić. Zdarzenie opisuje fakt, który już się wydarzył. OrderCreatedEvent może uruchomić wysłanie e-maila, aktualizację statystyk lub rezerwację towaru, ale nie powinien sam decydować o tym, czy zamówienie zostanie utworzone.
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
) {}
}import {
EventsHandler,
IEventHandler,
} from '@nestjs/cqrs';
import { OrderCreatedEvent } from './order-created.event';
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler
implements IEventHandler
{
async handle(event: OrderCreatedEvent): Promise {
console.log(`Nowe zamówienie: ${event.orderId}`);
}
} Handler zdarzenia trzeba zarejestrować w module razem z handlerem komendy. Jeśli tego zabraknie, kod może wyglądać poprawnie, ale zdarzenie nie zostanie obsłużone.
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { OrdersController } from './orders.controller';
import { CreateOrderHandler } from './application/commands/create-order.handler';
import { OrderCreatedHandler } from './domain/events/order-created.handler';
@Module({
imports: [CqrsModule],
controllers: [OrdersController],
providers: [
CreateOrderHandler,
OrderCreatedHandler,
],
})
export class OrdersModule {}Jak obsługiwać zapytania i budować read model
Zapytanie powinno zwracać dane potrzebne konkretnemu przypadkowi użycia. Nie próbuję tworzyć jednego uniwersalnego modelu odpowiedzi dla panelu administracyjnego, aplikacji mobilnej i raportu finansowego. Read model jest dopasowany do ekranu lub procesu, dlatego może zawierać denormalizowane dane i gotowe wartości obliczone wcześniej.
export class GetOrderQuery {
constructor(public readonly orderId: string) {}
}import {
IQueryHandler,
QueryHandler,
} from '@nestjs/cqrs';
import { GetOrderQuery } from './get-order.query';
import { OrdersReadRepository } from '../../infrastructure/orders-read.repository';
@QueryHandler(GetOrderQuery)
export class GetOrderHandler
implements IQueryHandler
{
constructor(
private readonly readRepository: OrdersReadRepository,
) {}
execute(query: GetOrderQuery) {
return this.readRepository.findDetails(query.orderId);
}
} Kontroler korzysta z QueryBus, a nie z CommandBus. Dzięki temu już na poziomie kodu widać, czy endpoint zmienia stan, czy tylko przygotowuje odpowiedź.
import { Controller, Get, Param } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { GetOrderQuery } from './application/queries/get-order.query';
@Controller('orders')
export class OrdersController {
constructor(private readonly queryBus: QueryBus) {}
@Get(':id')
getById(@Param('id') id: string) {
return this.queryBus.execute(new GetOrderQuery(id));
}
}Na początku oba repozytoria mogą korzystać z tej samej bazy i tabel. To nadal jest CQRS, ponieważ rozdzielenie dotyczy odpowiedzialności i przepływu aplikacji, a nie koniecznie dwóch serwerów bazodanowych. Oddzielne magazyny danych pojawiają się dopiero wtedy, gdy dają konkretną korzyść, na przykład szybsze raporty albo niezależne skalowanie odczytów.
Trzeba jednak świadomie obsłużyć spójność. Jeżeli read model jest aktualizowany na podstawie zdarzeń, użytkownik może przez krótki czas zobaczyć poprzedni stan. To tak zwana eventual consistency, czyli spójność osiągana z opóźnieniem. Dla historii zamówień może być akceptowalna, ale dla salda rachunku albo dostępności produktu często potrzebujemy odczytu z głównego źródła.
Dobrym miejscem na pokazanie całego przepływu jest aplikacja kliencka. Gdy interfejs korzysta z połączenia utrzymywanego z serwerem, sposób aktualizacji widoku i obsługi zdarzeń wymaga dodatkowych decyzji. Przydatne będzie omówienie, czym jest interaktywność serwerowa i jakie ma konsekwencje dla aplikacji webowej.
Jak połączyć CQRS z transakcjami i komunikacją asynchroniczną
Najczęstszy błąd polega na założeniu, że publikacja zdarzenia oznacza automatyczne zapewnienie spójności. Jeśli rekord zostanie zapisany w bazie, a proces aplikacji zakończy się przed publikacją zdarzenia, inne moduły nie dowiedzą się o zmianie. W systemach, w których nie można zgubić komunikatu, stosuję wzorzec outbox.
Outbox zapisuje dane biznesowe i komunikat o zdarzeniu w tej samej transakcji. Osobny proces odczytuje tabelę komunikatów i publikuje je ponownie, aż otrzyma potwierdzenie. To zwiększa niezawodność, ale także wymaga obsługi duplikatów, dlatego konsumenci zdarzeń powinni być idempotentni, czyli bezpieczni przy wielokrotnym przetworzeniu tego samego komunikatu.
Warto rozdzielić dwa przypadki:
- Operacje lokalne, takie jak utworzenie zamówienia i zapis jego pozycji, powinny zwykle działać w jednej transakcji.
- Integracje zewnętrzne, takie jak płatność lub wysyłka wiadomości, nie powinny być traktowane jak zwykła część tej samej transakcji bazodanowej.
- Długie procesy biznesowe można modelować jako proces menedżera lub sagę, która reaguje na zdarzenia i uruchamia kolejne komendy.
W handlerach nie ukrywam też błędów pod ogólnym wyjątkiem. Wyjątek domenowy, taki jak brak produktu, powinien dawać kontrolerowi przewidywalny kod HTTP, na przykład 409. Błąd infrastruktury, na przykład chwilowa niedostępność bazy, wymaga innego logowania, retry i monitoringu.
Jeżeli backend jest częścią większego ekosystemu, podobne decyzje architektoniczne pojawiają się także w innych technologiach. Przydatnym punktem odniesienia mogą być modele aplikacji ASP.NET Core, zwłaszcza gdy zespół łączy usługi NestJS z komponentami .NET.
Co najczęściej komplikuje wdrożenie CQRS
CQRS nie poprawia automatycznie jakości kodu. Jeśli każdy prosty odczyt otrzyma osobną klasę zapytania, handler, interfejs i repozytorium, projekt zacznie być pełen formalnych warstw bez realnej wartości. Największym kosztem jest narzut organizacyjny, dlatego wzorzec powinien obejmować moduły, w których rzeczywiście rozwiązuje problem.
| Problem | Skutek | Rozsądna reakcja |
|---|---|---|
| Komenda zawiera logikę odczytu danych do kilku ekranów. | Handler staje się trudny do testowania. | Rozdzielić przypadki użycia i przenieść odczyty do zapytań. |
| Każdy prosty endpoint ma własną abstrakcję. | Rośnie liczba klas bez poprawy czytelności. | Wprowadzać CQRS stopniowo, moduł po module. |
| Read model aktualizuje się asynchronicznie. | Użytkownik może zobaczyć nieaktualne dane. | Określić, gdzie opóźnienie jest dopuszczalne. |
| Handler publikuje zdarzenie przed zatwierdzeniem transakcji. | System może ogłosić zmianę, która ostatecznie się nie zapisze. | Publikować po commit albo użyć outboxa. |
| Zdarzenia nie mają stabilnego identyfikatora. | Trudniej wykrywać duplikaty i odtwarzać problemy. | Dodawać identyfikatory, metadane i korelację komunikatów. |
Nie łączę też automatycznie CQRS z event sourcingiem. Event sourcing przechowuje historię zdarzeń jako główne źródło prawdy, natomiast CQRS może działać na zwykłych tabelach z aktualnym stanem. Te techniki można stosować razem, ale każda rozwiązuje inny problem.
Podobnie mikroserwisy nie są wymagane. Dobrze zaprojektowany monolit NestJS z rozdzielonymi modułami często będzie lepszym początkiem niż kilka usług komunikujących się przez sieć. Dopiero gdy pojawi się potrzeba niezależnego wdrażania, skalowania lub izolacji zespołów, warto rozważyć rozdzielenie procesów.
Jak wdrażać ten wzorzec bez przepisywania całej aplikacji
Najbezpieczniej zacząć od jednego procesu biznesowego, który ma wyraźne reguły i kilka różnych sposobów odczytu. Dobrym kandydatem jest utworzenie zamówienia, zmiana statusu płatności albo import danych z zewnętrznego systemu. Prosty endpoint do pobrania pojedynczego rekordu zwykle nie daje wystarczającego powodu do przebudowy.
- Wybierz jeden moduł z realnym problemem, a nie cały system.
- Zdefiniuj komendy jako intencje biznesowe, na przykład
ConfirmPaymentCommand. - Przenieś logikę wykonania do handlerów i zostaw kontrolery bez reguł biznesowych.
- Dodaj zapytania tylko tam, gdzie odczyt wymaga osobnego modelu lub optymalizacji.
- Wprowadź zdarzenia dopiero wtedy, gdy istnieje konkretny odbiorca.
- Dodaj testy handlerów, test przepływu HTTP oraz testy idempotencji zdarzeń.
W testach jednostkowych sprawdzam przede wszystkim zachowanie handlera: jakie reguły blokują operację, jakie dane zapisuje i jakie zdarzenie publikuje. Test integracyjny powinien natomiast potwierdzić, że NestJS prawidłowo odkrywa handlery, a cały przepływ przez CommandBus lub QueryBus działa po uruchomieniu modułu.
Przy większej liczbie zdarzeń przydają się metryki opóźnienia, liczba ponowień i liczba komunikatów zakończonych błędem. Bez tego asynchroniczny przepływ może sprawiać wrażenie zdrowego, mimo że część zdarzeń od tygodni nie jest obsługiwana.
Najmniejszy sensowny plan dla projektu NestJS
Jeżeli aplikacja jest niewielka, zacząłbym od jednego modułu, kilku komend i jednego read modelu. Nie ma potrzeby od razu wprowadzać osobnej bazy, brokera wiadomości ani event sourcingu. Najpierw trzeba sprawdzić, czy rozdzielenie faktycznie poprawia testowalność i czytelność kodu.
Największą wartość daje konsekwentne rozdzielenie odpowiedzialności. Komenda zmienia stan, zapytanie go odczytuje, zdarzenie informuje o fakcie, a kontroler tylko przekazuje żądanie dalej. Taki prosty podział zwykle robi większą różnicę niż rozbudowana infrastruktura, którą można dodać dopiero wtedy, gdy pojawi się konkretny problem.
