Rozdzielenie operacji odczytu i zapisu może znacznie uprościć architekturę aplikacji. W świecie .NET coraz częściej wykorzystuje się do tego wzorzec CQRS (Command Query Responsibility Segregation) wraz z biblioteką MediatR. Dzięki temu nasze aplikacje zyskują na wydajności i czytelności kodu. W tym artykule wyjaśniam, na czym polega podejście CQRS, jak MediatR pomaga je zaimplementować oraz kiedy warto rozważyć takie rozwiązanie, a kiedy może to być nadmiarowe. Artykuł zainteresuje zwłaszcza architektów i liderów technicznych, którzy stoją przed decyzją dotyczącą złożoności aplikacji.
CQRS – rozdzielenie odczytu od zapisu
CQRS to skrót od Command Query Responsibility Segregation, czyli segregacji odpowiedzialności poleceń i zapytań. Jak sama nazwa wskazuje, chodzi o podział operacji modyfikujących stan (poleceń) i operacji odczytu danych (zapytań) na osobne ścieżki i modele. Zamiast używać tych samych struktur danych i metod do odczytu oraz zapisu, w CQRS tworzymy 2 odrębne modele: jeden do zapisów (poleceń) i inny do odczytów (zapytań). Taki podział pozwala zoptymalizować każdy z tych obszarów niezależnie. Modele do zapisu mogą być bogatsze (np. pełny obiekt domenowy do walidacji biznesowej), a modele do odczytu lżejsze i dopasowane do potrzeb widoku (np. zawierające tylko niezbędne pola, często już zagregowane). W efekcie aplikacja staje się prostsza w rozwoju i bardziej skalowalna, zwłaszcza gdy wzorce odczytu i zapisu znacząco się różnią.
Tradycyjne podejście CRUD korzysta często z tych samych obiektów/DTO zarówno do odczytu, jak i zapisu danych. W prostych aplikacjach to wystarcza, ale w miarę wzrostu złożoności pojawiają się problemy. Dane potrzebne do operacji zapisu nie zawsze pokrywają się z danymi potrzebnymi do odczytu. Na przykład, przy modyfikacji obiektu może być wymagane więcej informacji (walidacje, reguły biznesowe) niż przy wyświetlaniu tego obiektu użytkownikowi. Używanie jednego, uniwersalnego modelu dla obu celów prowadzi często do kompromisów: model staje się przepakowany (zawiera pola niepotrzebne dla części operacji) lub brakuje w nim informacji potrzebnych do innych operacji, co skutkuje dodatkowymi komplikacjami. CQRS rozwiązuje ten problem poprzez dwa wyspecjalizowane modele danych - jeden do modyfikowania stanu, drugi do odczytywania informacji.
Dlaczego to ma znaczenie? Ponieważ dzięki rozdzieleniu:
• Możemy optymalizować wydajność każdej ze stron niezależnie. W wielu systemach odczyt danych jest wykonywany znacznie częściej niż zapis. CQRS pozwala usprawnić stronę odczytu, np. poprzez cache'owanie, projekcje danych, czy nawet osobną bazę danych zoptymalizowaną pod zapytania, bez wpływu na część zapisującą. Jeżeli większość obciążenia to zapytania, możemy skalować i tuningować tylko tę część (np. wprowadzając indeksy, replikację bazy tylko-do-odczytu, itp.), co znacząco poprawi wydajność całego systemu. Z drugiej strony, część odpowiedzialna za zapisy może być projektowana z myślą o integralności danych i złożonych regułach biznesowych, nie martwiąc się o wydajność odczytu.
• Kod staje się bardziej czytelny i łatwiejszy w utrzymaniu. Rozdzielając logikę zapisu od logiki odczytu otrzymujemy mniejsze, wyspecjalizowane klasy i funkcje, z których każda ma jedno, jasno określone zadanie. Zamiast monolitycznych metod realizujących wszystko, mamy np. osobno handler polecenia tworzącego nowy obiekt i osobno handler zapytania pobierającego listę obiektów. Taki kod łatwiej czytać, testować i modyfikować, bo każda część jest prostsza i niezależna. Deweloperzy nie muszą analizować pobocznych efektów. Wiadomo, że zapytanie nie zmieni stanu systemu, a polecenie nie zwróci danych oprócz ewentualnego potwierdzenia sukcesu. To też ułatwia testowanie jednostkowe, możemy testować logikę zapisu i odczytu oddzielnie.
• Unikamy wzajemnego wpływu odczytów i zapisów na siebie. W tradycyjnej architekturze każda zmiana w strukturze bazy danych czy modelu danych dla potrzeb zapisu może potencjalnie zepsuć raporty lub widoki odczytujące dane i odwrotnie. CQRS daje swobodę zaprojektowania osobno modelu do zapisu i osobno do odczytu, dzięki czemu zmiany w jednym (np. normalizacja tabel dla spójności transakcyjnej) nie komplikują życia drugiemu (który może używać np. zdenormalizowanych widoków dla szybkości). Każda strona dostaje "to, co lubi najbardziej". To uwalnia nas od ciągłego balansowania, który aspekt jest ważniejszy. W dużych systemach może to iść jeszcze dalej, osobne zespoły mogą rozwijać część do odczytu i część do zapisu, komunikując się przez jasno zdefiniowane kontrakty.
Oczywiście, nie ma róży bez kolców. Podejście CQRS dodaje pewną złożoność. Piszemy więcej kodu (oddzielne klasy komend, zapytań, osobne DTO itd.), co zwiększa objętość projektu. Trzeba też zadbać o synchronizację modeli danych. Jeśli korzystamy z oddzielnych baz danych czy nawet oddzielnych warstw, pojawia się wyzwanie utrzymania spójności (czasem akceptujemy spójność ostateczną, gdzie odczyt może przez chwilę nie widzieć najnowszego zapisu). Dlatego CQRS należy stosować z rozwagą. Jeśli nasza aplikacja jest prosta, a logika biznesowa niewielka, rozdzielanie wszystkiego na siłę może być przerostem formy nad treścią i przedwczesną optymalizacją. Jednak w skomplikowanych systemach, gdzie wymogi odczytu i zapisu są inne lub skala operacji rośnie, CQRS staje się potężnym narzędziem pozwalającym utrzymać porządek i wydajność.
MediatR - mediatyzacja żądań w .NET
W implementacji CQRS w aplikacjach .NET z pomocą przychodzi biblioteka MediatR. Została ona napisana przez Jimmy'ego Bogarda i realizuje w praktyce wzorzec Mediatora. Mediator to pośrednik, który kieruje komunikacją między komponentami. Zamiast wywoływać metody bezpośrednio, komponenty wysyłają komunikaty/żądania do mediatora, a on dba o wywołanie odpowiedniego obsługującego (handlera).
W kontekście CQRS, MediatR pozwala nam łatwo zaimplementować rozdział odczytu i zapisu wewnątrz jednej aplikacji. Jak to wygląda w praktyce? Definiujemy osobne klasy zapytań (Query) i poleceń (Command), zazwyczaj implementujące interfejs IRequest<T> (gdzie T to typ wyniku, np. dla komendy często Unit lub jakiś identyfikator, a dla zapytania - np. konkretny DTO lub lista DTO). Następnie tworzymy handlery implementujące IRequestHandler<TRequest, TResponse> dla każdego typu żądania. Taki handler zawiera całą logikę związaną z obsługą danego polecenia lub zapytania (np. zapis do bazy, lub pobranie danych i mapowanie do DTO).
Przykład prostego zapytania i komendy z wykorzystaniem MediatR może wyglądać tak:
/* Definicja zapytania (Query) */
public record GetProductsQuery(int CategoryId) : IRequest<List<ProductDto>>;
/* Definicja polecenia (Command) */
public record AddProductCommand(NewProductDto NewProduct) : IRequest<Guid>;Każde z powyższych żądań będzie miało swój handler
/* Handler obsługujący zapytanie o listę produktów */
public class GetProductsHandler : IRequestHandler<GetProductsQuery, List<ProductDto>>
{
private readonly ShopContext _db;
public GetProductsHandler(ShopContext db) => _db = db;
public async Task<List<ProductDto>> Handle(GetProductsQuery request, CancellationToken ct)
{
/* Przykład: pobierz z bazy produkty po ID kategorii i zmapuj na DTO */
var products = await _db.Products
.Where(p => p.CategoryId == request.CategoryId)
.ToListAsync(ct);
return products.Select(p => new ProductDto(p)).ToList();
}
}
/* Handler obsługujący polecenie dodania nowego produktu */
public class AddProductHandler : IRequestHandler<AddProductCommand, Guid>
{
private readonly ShopContext _db;
public AddProductHandler(ShopContext db) => _db = db;
public async Task<Guid> Handle(AddProductCommand request, CancellationToken ct)
{
/* Przykład: dodaj nowy produkt do bazy */
var product = new Product
{
Name = request.NewProduct.Name,
Price = request.NewProduct.Price,
CategoryId = request.NewProduct.CategoryId
};
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
return product.Id; /* zwróć ID nowo dodanego produktu */
}
}W kontrolerze lub serwisie wywołującym możemy teraz nie znać szczegółów implementacji tych operacji, wystarczy, że wstrzykniemy IMediator (interfejs mediatora z biblioteki MediatR) i wyślemy odpowiednie żądanie:
/* Przykład użycia mediatora w kontrolerze (Minimal API lub MVC) */
var createdProductId = await _mediator.Send(new AddProductCommand(newProductDto));
var categoryProducts = await _mediator.Send(new GetProductsQuery(categoryId));Mediator pod spodem znajdzie i wywoła właściwy handler. Dzięki temu kontrolery pozostają szczupłe (Thin Controllers). Nie zawierają logiki biznesowej ani dostępowej do bazy, a tylko przekazują żądania do odpowiednich handlerów. To zgodne z ideą czystej architektury i Vertical Slice Architecture (gdzie kod organizuje się wokół funkcjonalności zamiast warstw). Rezultat: kod jest lepiej zorganizowany, a zależności między komponentami luźno powiązane.
Co ważne, MediatR umożliwia także dodawanie tzw. behavior pipelines, czyli np. globalnych dekoratorów wokół naszych handlerów (np. logowanie, walidacja, obsługa transakcji) bez zaśmiecania logiki biznesowej. To kolejny bonus do utrzymania czystości kodu.
Podsumowując rolę MediatR: jest on klejem, który spaja implementację CQRS wewnątrz aplikacji .NET. Pozwala za pomocą prostego API (.Send(), ewentualnie .Publish() dla zdarzeń/notifykacji) przekazywać polecenia i zapytania do obsługi, mediując pomiędzy warstwą prezentacji (np. kontrolerem API) a właściwą logiką. Wprowadzenie tej biblioteki jest proste, instalujemy pakiet NuGet, rejestrujemy MediatR w kontenerze DI i gotowe. Zyskujemy za to dużą poprawę struktury projektu: czytelność, testowalność i możliwość łatwej rozbudowy nowych funkcjonalności przez dodanie nowych handlerów zamiast modyfikacji istniejących klas.
Kiedy warto zastosować CQRS z MediatR?
Wielu programistów zadaje sobie pytanie: czy moja aplikacja potrzebuje CQRS, czy to już over-engineering? Nie ma jednej prostej odpowiedzi, ale kilka wskazówek może pomóc podjąć decyzję:
• Złożone domeny i logika biznesowa. Jeśli projekt zawiera obszerną logikę biznesową przy modyfikacji danych (np. skomplikowane walidacje, zdarzenia domenowe, zmiany rozchodzące się na wiele agregatów), a jednocześnie wymaga bardzo szybkich odczytów (np. raporty, widoki agregujące dane z wielu miejsc) - CQRS pozwoli oddzielić te dwa światy. Część do zapisu może wtedy skupić się na poprawności i regułach (może nawet z event sourcingiem w tle), a część do odczytu na wydajnym serwowaniu danych użytkownikom. Przykład: system bankowy, gdzie transakcje muszą być ściśle walidowane i spójne, ale generowanie wyciągu czy historii konta dla użytkownika powinno odbywać się błyskawicznie (nawet z pewnym opóźnieniem względem transakcji).
• Przewaga operacji odczytu nad zapisem. W systemach, w których czytamy znacznie częściej niż piszemy, opłaca się zoptymalizować odczyt osobno. Typowy przykład to aplikacje typu e-commerce: setki użytkowników przeglądają produkty (odczyt) podczas gdy edycja oferty (zapis) jest rzadsza. Wprowadzenie CQRS umożliwi np. utrzymywanie zdenormalizowanych danych produktowych do wyświetlania list (szybki odczyt), podczas gdy część zapisująca może utrzymywać normalizowaną bazę danych dla spójności. Można też skalować poziomo samą warstwę odczytu (więcej serwerów cache/replic bazy danych) niezależnie od warstwy zapisu.
• Różne wymagania niefunkcjonalne dla odczytu i zapisu. Czasem odczyt i zapis podlegają różnym ograniczeniom. Np. odczyt musi mieć minimalne opóźnienie (niski latency) i obsłużyć ogromne wolumeny zapytań, a zapis musi zapewnić pełną transakcyjność i bezpieczeństwo danych. Rozdzielenie pozwala dobrać osobne technologie dla każdego z tych zadań. Np. zapisy w relacyjnej bazie transakcyjnej, a odczyty z szybkiej bazy NoSQL lub cache. W kontekście .NET na poziomie kodu, nawet jeśli używamy jednej bazy, możemy zastosować inne podejścia: zapytania realizować za pomocą wyspecjalizowanych zapytań SQL (np. VIEW w bazie lub zapytania LINQ pod konkretne DTO), a zapisy przez solidne encje domenowe z walidacją.
• Chęć utrzymania porządku w kodzie przy rozrastającym się projekcie. CQRS z MediatR wymusza pewną strukturę - komendy, zapytania, handlery. Gdy projekt rośnie, łatwiej dodawać nowe funkcjonalności, trzymając się tego schematu (to podejście bywa nazywane Vertical Slice, gdzie każdy przypadek użycia jest zaimplementowany end-to-end w swoim "kawałku" kodu). Dla doświadczonych programistów i architektów może to być lepsze niż klasyczna, warstwowa architektura, gdzie po pewnym czasie kontrolery i serwisy puchną od dziesiątek metod. Tu każdy handler jest krótki i konkretny, a kontroler tylko deleguje zadanie do mediatora. Taka przejrzystość ułatwia też onboarding nowych członków zespołu, łatwiej zlokalizować, gdzie znajduje się logika dla danej funkcjonalności.
Z drugiej strony, nie zawsze CQRS jest potrzebne. Jeśli aplikacja jest niewielka lub typowo CRUD-owa, dodawanie całej tej infrastruktury może nie być warte zachodu. Nadmiarowość kodu może wtedy paradoksalnie utrudnić zrozumienie prostego przepływu (więcej plików, klas, interfejsów do prześledzenia). Zawsze kieruj się zasadą: zastosować CQRS tylko wtedy, gdy przyniesie to realne korzyści (lepsza wydajność, skalowalność, czytelność) przewyższające koszt złożoności. W praktyce sprawdza się to w średnich i dużych systemach, gdzie liczba operacji i różnorodność wymagań rośnie. Gdy czujesz, że "jedna warstwa serwisowa zaczyna nie domagać", że wasz kod staje się nieczytelny od mieszania logiki różnych przypadków użycia - to może być znak, że pora rozważyć CQRS.
Podsumowanie
CQRS z MediatR to sprawdzone podejście, które pomaga tworzyć szybsze i bardziej uporządkowane aplikacje .NET, ale powinno być używane świadomie. Rozdzielenie odczytu od zapisu może przynieść ogromne korzyści w złożonych projektach - zwiększyć wydajność (dzięki możliwości niezależnej optymalizacji każdej strony) oraz poprawić czytelność i utrzymanie kodu (przez wyraźny podział odpowiedzialności). Jednak nie jest to "srebrna kula" i w prostych przypadkach lepiej zachować prostotę.
Jeśli zainteresowało Cię takie architektoniczne podejście i chcesz pogłębić swoją wiedzę o tworzeniu skalowalnych aplikacji w .NET, rozważ udział w moich kursach online. W ramach szkoleń takich jak Szkoła Aplikacji SaaS czy Szkoła ASP.NET Core omawiamy m.in. wzorce projektowe i dobre praktyki architektoniczne, które pomagają unikać chaosu w kodzie. Jeżeli nie wiesz, który kurs wybrać, zachęcam do zapoznania się ze wszystkimi szkoleniami dostępnymi na mojej stronie: modestprogrammer.pl/kursy. Udział w kursie to świetny sposób, aby nauczyć się więcej o tematach pokroju CQRS w praktyce i wynieść swoje umiejętności na wyższy poziom.