CQRS w C# — rozdzielenie zapisu od odczytu, krok po kroku
Zastrzeżenie na start: CQRS (Command Query Responsibility Segregation) to nie jeden z 23 wzorców GoF — to wzorzec architektoniczny spopularyzowany przez Grega Younga, rozwijający zasadę Command-Query Separation Bertranda Meyera na poziom całej aplikacji, nie pojedynczej metody. Opisujemy go tu w podobnym formacie co wzorce GoF, bo ten sam sposób czytania (Intent, Motywacja, Konsekwencje) sprawdza się równie dobrze poza książką Gang of Four.
1. Intent (Zamierzenie)
Rozdziela model używany do zmiany stanu (zapis) od modelu używanego do odczytu danych, tak by każdy z nich mógł być projektowany, optymalizowany i skalowany niezależnie.
Krótko: zamiast jednego modelu do wszystkiego, masz osobną ścieżkę dla “zmień coś” (Command) i osobną dla “pokaż mi coś” (Query).
2. Also Known As (Inne nazwy)
Bazuje na Command-Query Separation (CQS) — zasadzie Bertranda Meyera z poziomu pojedynczej metody (“metoda albo coś zmienia, albo coś zwraca, nigdy oba naraz”). CQRS to ta sama idea, przeniesiona z metody na architekturę całej aplikacji.
3. Motivation (Motywacja)
Typowy serwis CRUD używa jednego modelu Order zarówno do zapisu (PlaceOrder, z pełną walidacją reguł biznesowych), jak i do odczytu (GetOrderSummary, gdzie potrzebujesz spłaszczonych, połączonych danych z kilku tabel na potrzeby listy zamówień w UI).
Problem narasta wraz z aplikacją: model zapisu musi pilnować niezmienników domenowych (nie można wysłać zamówienia bez pozycji, nie można anulować już wysłanego) — model odczytu nie potrzebuje żadnej z tych reguł, za to potrzebuje danych połączonych z klientem, produktami i statusem płatności w jednym zapytaniu. Wymuszanie obu przez ten sam model kończy się albo przeładowanym encją SQL-em w warstwie domenowej, albo walidacją biznesową wyciekającą do zapytań tylko-do-odczytu.
4. Applicability (Kiedy stosować)
Stosuj CQRS gdy:
- odczyt i zapis mają skrajnie różne wymagania wydajnościowe (dużo odczytów, rzadkie zapisy, albo odwrotnie),
- model odczytu potrzebuje zdenormalizowanych, połączonych danych z wielu agregatów, których zapis w ogóle nie dotyczy,
- złożona logika domenowa przy zapisie nie ma nic wspólnego z zoptymalizowanymi zapytaniami odczytu,
- chcesz skalować odczyty i zapisy niezależnie (np. read replica dla zapytań, osobna baza pod zapis).
Nie stosuj do prostych aplikacji CRUD — tam dodatkowa warstwa abstrakcji to koszt bez korzyści.
5. Structure (Struktura)
ZAPIS (Command) ODCZYT (Query)
┌────────────┐ ┌──────────────────┐ ┌────────────┐ ┌──────────────────┐
│ Client │──>│ PlaceOrderCommand│ │ Client │──>│ OrderSummaryQuery │
└────────────┘ └──────────────────┘ └────────────┘ └──────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ PlaceOrderHandler │ │ OrderSummaryHandler │
│ (waliduje, egzekwuje │ │ (czyta bezpośrednio, │
│ reguły domenowe) │ │ pomija model domeny) │
└──────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Order (agregat) │ │ OrderSummaryDto │
│ → baza zapisu │ │ → baza odczytu │
└──────────────────────┘ └──────────────────────┘
Dwie oddzielne, niezależne ścieżki — command idzie przez pełny model domenowy z walidacją, query idzie bezpośrednio do płaskiego DTO, bez przechodzenia przez logikę biznesową.
6. Participants (Uczestnicy)
- Command — obiekt opisujący zamiar zmiany stanu (np.
PlaceOrderCommand), nie zwraca danych domenowych. - CommandHandler — waliduje command, egzekwuje reguły biznesowe, zapisuje zmianę.
- Query — obiekt opisujący żądanie danych (np.
GetOrderSummaryQuery), nigdy niczego nie zmienia. - QueryHandler — czyta dane bezpośrednio ze źródła zoptymalizowanego pod odczyt, pomijając model domenowy.
7. Collaborations (Współpraca)
Client wysyła Command, gdy chce zmienić stan — CommandHandler przepuszcza go przez pełną logikę domenową. Client wysyła Query, gdy chce dane — QueryHandler czyta je bezpośrednio, z pominięciem modelu zapisu.
Kluczowe: QueryHandler nie woła CommandHandlera ani modelu domenowego. To dwie równoległe, nieprzecinające się ścieżki.
8. Consequences (Konsekwencje)
Plus: model odczytu i zapisu optymalizujesz niezależnie — odczyt może być płaski i szybki, zapis może mieć pełne bogactwo reguł domenowych. Plus: możesz skalować odczyty i zapisy osobno (np. cache albo read replica tylko dla zapytań). Minus: więcej kodu i ruchomych części niż w jednym serwisie CRUD. Minus: jeśli fizycznie rozdzielisz bazy zapisu i odczytu, dochodzi eventual consistency — dane w modelu odczytu mogą przez chwilę być nieaktualne.
CQRS w C# — od diagramu do kodu
Bez CQRS — jeden serwis do wszystkiego
public class OrderServiceZly
{
public void PlaceOrder(Order order)
{
if (order.Items.Count == 0)
throw new InvalidOperationException("Zamówienie musi mieć pozycje");
// ... reguły biznesowe, zapis do bazy
}
public OrderSummaryDto GetOrderSummary(int orderId)
{
// ❌ ten sam serwis odpytuje kilka tabel na potrzeby widoku listy,
// mieszając logikę odczytu z logiką domenową PlaceOrder
var order = _repository.GetById(orderId);
var klient = _klienciRepo.GetById(order.KlientId);
var platnosc = _platnosciRepo.GetByOrderId(orderId);
return new OrderSummaryDto(order.Id, klient.Imie, order.Suma, platnosc.Status);
}
}
OrderServiceZly odpowiada za dwie zupełnie różne rzeczy: egzekwowanie reguł biznesowych przy zapisie i budowanie zoptymalizowanego widoku przy odczycie. Zmiana reguły walidacji przy zapisie i zmiana kształtu DTO przy odczycie to dwa niepowiązane powody do zmiany tej samej klasy.
Command + Handler (zapis)
public record PlaceOrderCommand(int KlientId, List<OrderItem> Pozycje);
public class PlaceOrderCommandHandler
{
private readonly IOrderRepository _repository;
public PlaceOrderCommandHandler(IOrderRepository repository) => _repository = repository;
public int Handle(PlaceOrderCommand command)
{
if (command.Pozycje.Count == 0)
throw new InvalidOperationException("Zamówienie musi mieć pozycje");
var order = new Order(command.KlientId, command.Pozycje); // pełna logika domenowa
_repository.Save(order);
return order.Id;
}
}
Query + Handler (odczyt)
public record GetOrderSummaryQuery(int OrderId);
public class GetOrderSummaryQueryHandler
{
private readonly IDbConnection _db;
public GetOrderSummaryQueryHandler(IDbConnection db) => _db = db;
public OrderSummaryDto Handle(GetOrderSummaryQuery query)
{
// Bezpośrednie, zoptymalizowane zapytanie — bez przechodzenia
// przez encję Order i jej logikę domenową
const string sql = """
SELECT o.Id, k.Imie, o.Suma, p.Status
FROM Orders o
JOIN Klienci k ON k.Id = o.KlientId
JOIN Platnosci p ON p.OrderId = o.Id
WHERE o.Id = @OrderId
""";
return _db.QuerySingle<OrderSummaryDto>(sql, new { query.OrderId });
}
}
Odczyt idzie prosto do bazy jednym zapytaniem SQL zwracającym gotowy DTO — zero pośredniczącego modelu domenowego, zero narzutu na mapowanie encji, które i tak zaraz spłaszczysz z powrotem do widoku listy.
Spięcie przez dispatcher (opcjonalnie — MediatR)
W realnych projektach .NET command/query handlery najczęściej spina biblioteka MediatR (ten sam mechanizm dispatchowania, który poznałeś przy Mediator Pattern — CQRS bardzo często korzysta z Mediatora jako warstwy transportowej między controllerem a handlerem):
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
[HttpPost]
public async Task<IActionResult> PlaceOrder(PlaceOrderCommand command)
=> Ok(await _mediator.Send(command));
[HttpGet("{id}/summary")]
public async Task<IActionResult> GetSummary(int id)
=> Ok(await _mediator.Send(new GetOrderSummaryQuery(id)));
}
Controller nie wie, który handler obsłuży żądanie — zna tylko IMediator, dokładnie tak jak Uzytkownik w poprzednim poście znał tylko IChatMediator.
Pułapka: eventual consistency przy fizycznie rozdzielonych bazach
Jeśli model odczytu żyje w osobnej, zdenormalizowanej bazie synchronizowanej asynchronicznie (np. przez zdarzenia domenowe), między zapisem a jego widocznością w odczycie mija czas — użytkownik może kliknąć “Złóż zamówienie” i przez ułamek sekundy nie zobaczyć go na liście. To akceptowalne w wielu systemach (media społecznościowe, analityka), ale nieakceptowalne, gdy użytkownik oczekuje natychmiastowego potwierdzenia (np. płatności). Jeśli Twój przypadek użycia wymaga silnej spójności — trzymaj model odczytu i zapisu w tej samej bazie transakcyjnej; rozdzielaj fizycznie dopiero, gdy realnie potrzebujesz niezależnego skalowania.
Kiedy NIE stosować CQRS
- Prosty CRUD bez rozbieżnych wymagań odczytu/zapisu — jeden model, jeden serwis, mniej kodu do utrzymania.
- Mały zespół, mała aplikacja — koszt utrzymania dwóch ścieżek (command/query, ewentualnie dwóch baz) rzadko się zwraca poniżej pewnej skali.
- Brak realnego problemu wydajnościowego z odczytem — jeśli obecny model dobrze obsługuje i zapis, i odczyt, CQRS to złożoność szukająca problemu.
Podsumowanie
CQRS rozdziela dwie fundamentalnie różne odpowiedzialności — zmianę stanu i odczyt danych — na osobne modele, każdy zoptymalizowany pod swoje zadanie. Command przechodzi przez pełną logikę domenową, Query czyta bezpośrednio, z pominięciem tej logiki. To wzorzec architektoniczny, nie GoF-owy wzorzec projektowy — i, jak każde narzędzie do skalowania złożoności, ma sens dopiero, gdy złożoność faktycznie istnieje. W prostym CRUD-zie to koszt bez korzyści.
🚀 Co dalej?
Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
Zamień wiedzę w umiejętności
Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.
Pobieram roadmapę →