Kod C# rozdzielający komendę PlaceOrder i zapytanie GetOrderSummary.

Lista zamówień potrzebuje numeru, nazwy klienta, wartości i statusu płatności. Potwierdzenie zamówienia musi sprawdzić pozycje, ceny oraz dozwolone przejście stanu. Gdy obie potrzeby obsługuje ten sam model, do agregatu zaczynają trafiać pola do sortowania, dane do raportów i kolejne warianty ładowania relacji.

CQRS pozwala rozdzielić te odpowiedzialności. Zapis korzysta z modelu pilnującego reguł biznesowych, a odczyt dostaje model dopasowany do konkretnego ekranu. Możesz to zrobić w monolicie, z jedną bazą SQL i bez magazynu zdarzeń.

CQRS i Event Sourcing odpowiadają na różne pytania

CQRS, czyli Command Query Responsibility Segregation, dotyczy rozdzielenia modeli zmiany i odczytu stanu. Komenda wyraża zamiar, na przykład SubmitOrder. Zapytanie zwraca dane, na przykład GetCustomerOrders, bez wykonywania operacji biznesowej zmieniającej zamówienie.

Event Sourcing dotyczy sposobu przechowywania stanu. Źródłem prawdy jest uporządkowana historia zdarzeń, z której odtwarzasz bieżący stan obiektu. W zwykłym modelu persystencji źródłem prawdy pozostają aktualne rekordy, na przykład Orders i OrderLines.

Możesz więc mieć osobne modele zapisu i odczytu, a zamówienie nadal zapisywać przez UPDATE Orders. Możesz też publikować zdarzenia przez outbox i nadal przechowywać stan w tabelach. Samo wysłanie OrderSubmitted ani posiadanie tabeli audytowej nie tworzy Event Sourcingu. To rozróżnienie opisują osobno wzorce CQRS i Event Sourcing w Azure Architecture Center.

Najprostszy punkt startowy

Osobne modele i ścieżki wykonania mogą korzystać z tych samych tabel. Broker, osobny serwis, MediatR i druga baza są niezależnymi decyzjami. Dobieraj je do konkretnej potrzeby.

Wariant pierwszy: osobne modele, wspólna baza

Załóżmy, że moduł zamówień używa EF Core. Zapis ładuje śledzony agregat wraz z pozycjami, wywołuje jego metodę i zapisuje zmianę. Odczyt wykonuje projekcję SQL bez odtwarzania obiektu domenowego. Obie ścieżki korzystają z jednej podstawowej bazy danych:

  • Zapis: żądanie HTTP → handler komendy → agregat Order → SaveChanges.
  • Odczyt: żądanie HTTP → handler zapytania → projekcja z tabel → OrderListItem.

DTO listy nie potrzebuje metod Submit() ani Cancel(). Agregat nie potrzebuje nazwy klienta do wyświetlenia w tabeli ani pola CanShowPaymentBadge. Zapytanie może łączyć dane z kilku agregatów, jeśli mieści się to w granicach modułu i uprawnień do danych. Reguły zmiany zamówienia nadal pozostają po stronie zapisu.

Ten wariant nie wprowadza asynchronicznej synchronizacji modeli. Odczyt z podstawowej bazy, rozpoczęty po zatwierdzeniu zapisu i w nowej transakcji, może zobaczyć zmianę od razu. Cache, replika odczytowa lub wcześniej otwarta transakcja mogą zmienić tę gwarancję, dlatego trzeba nazwać je osobno.

Komenda: zamiar, reguły i konflikt zapisu

Poniższy fragment pokazuje ścieżkę złożenia zamówienia. Zakładamy, że Order.Submit() sprawdza między innymi obecność pozycji i dozwolony status. currentCustomer.Id pochodzi z uwierzytelnionej sesji. Konfiguracja encji, obsługa HTTP i implementacja agregatu są pominięte.

public sealed record SubmitOrder(
    Guid OrderId, byte[] ExpectedVersion);

public sealed record SubmitOrderResult(
    Guid OrderId, string Status, byte[] Version);

public async Task<SubmitOrderResult> Handle(
    SubmitOrder command, CancellationToken ct)
{
    var order = await db.Orders
        .Include(x => x.Lines)
        .SingleOrDefaultAsync(x =>
            x.Id == command.OrderId &&
            x.CustomerId == currentCustomer.Id, ct)
        ?? throw new OrderNotFoundException();

    db.Entry(order).Property(x => x.Version)
        .OriginalValue = command.ExpectedVersion;

    order.Submit();
    await db.SaveChangesAsync(ct);

    return new SubmitOrderResult(
        order.Id, order.Status.ToString(), order.Version);
}

W tym przykładzie Version jest tokenem współbieżności skonfigurowanym przez IsRowVersion() dla SQL Server. Klient przesyła wersję, którą widział podczas edycji. Jeśli inny zapis zdążył ją zmienić, EF Core zgłosi DbUpdateConcurrencyException; warstwa HTTP powinna przełożyć go na konflikt i umożliwić odświeżenie danych. Dla innej bazy dobierz obsługiwany przez nią token. Sam podział na komendy i zapytania nie chroni przed utraconą aktualizacją.

Komenda może zwrócić identyfikator, nową wersję i wynik operacji. Nie trzeba wymuszać pustej odpowiedzi i dodatkowego odczytu tylko po to, żeby przestrzegać nazewnictwa. Rozstrzygnij też ponowienie żądania: po utracie odpowiedzi klient może wysłać komendę ponownie. Operacje tworzące zamówienie lub zlecające płatność zwykle potrzebują klucza idempotencji i trwałego zapisu wyniku.

Zapytanie: dane pod konkretny ekran

Lista zamówień może korzystać z osobnego kontraktu odczytu. Przykład zakłada pojedynczą walutę sklepu i limituje rozmiar strony. Status jest w DTO typem wyliczeniowym; sposób jego prezentacji należy do interfejsu.

public sealed record OrderListItem(
    Guid Id,
    string Number,
    OrderStatus Status,
    decimal Total,
    DateTimeOffset CreatedAt);

public async Task<List<OrderListItem>> GetCustomerOrders(
    int page, int pageSize, CancellationToken ct)
{
    page = Math.Clamp(page, 1, 10_000);
    pageSize = Math.Clamp(pageSize, 1, 100);

    return await db.Orders
        .AsNoTracking()
        .Where(x => x.CustomerId == currentCustomer.Id)
        .OrderByDescending(x => x.CreatedAt)
        .ThenByDescending(x => x.Id)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(x => new OrderListItem(
            x.Id,
            x.Number,
            x.Status,
            x.Lines.Sum(line =>
                (decimal?)(line.Quantity * line.UnitPrice)) ?? 0m,
            x.CreatedAt))
        .ToListAsync(ct);
}

Projekcja pozwala pobrać potrzebne kolumny bez ładowania całego grafu encji. W tym uproszczonym modelu wartość zamówienia jest sumą pozycji. Jeśli cena końcowa uwzględnia rabaty, podatki i zaokrąglenia, model odczytu powinien korzystać z kwoty wyliczonej podczas zapisu; duplikowanie reguł cenowych w SQL grozi innym wynikiem na ekranie.

AsNoTracking() opisuje zamiar odczytu, lecz nie zastępuje autoryzacji. Filtr klienta musi pochodzić z zaufanego kontekstu. W systemie wielofirmowym także identyfikator firmy powinien ograniczać zapytanie. Dotyczy to list, eksportów, liczników i cache — każdy z tych odczytów może ujawnić cudze dane.

Oddzielny model daje swobodę użycia EF Core, Dappera lub zwykłego SQL. Wybór narzędzia uzasadnij planem zapytania i wygodą utrzymania. Najpierw sprawdź indeksy, liczbę zwracanych wierszy i koszt sortowania. Przy głębokiej paginacji oraz częstych nowych rekordach rozważ kursor oparty na CreatedAt i Id; samo Skip nie gwarantuje stabilnych stron podczas zmian danych.

Kiedy CQRS ma sens bez Event Sourcingu

Najbardziej przekonujący sygnał pojawia się wtedy, gdy potrzeby odczytu i zapisu rozwijają się w różne strony:

  • Zapis ma istotne reguły. Złożenie, anulowanie i zwrot zamówienia mają inne warunki niż zwykła edycja pól.
  • Ekrany wymagają różnych kształtów danych. Lista, szczegóły i raport nie potrzebują tej samej reprezentacji obiektu.
  • Zapytania komplikują model domenowy. Do agregatu trafiają relacje i pola potrzebne wyłącznie do wyświetlania.
  • Odczyt wymaga osobnej optymalizacji. Zmiana projekcji, indeksu lub sposobu stronicowania powinna być możliwa bez zmiany reguł biznesowych.

Duża przewaga liczby odczytów nad zapisami jest wskazówką do pomiaru obciążenia. W wielu aplikacjach wystarczą poprawne indeksy, cache albo replika. Sam stosunek liczby żądań nie uzasadnia osobnej bazy i synchronizacji danych.

W prostym panelu CRUD z kilkoma formularzami i niewieloma regułami dodatkowe kontrakty oraz handlery mogą kosztować więcej, niż dają. CQRS można wprowadzić tylko w module zamówień, pozostawiając słowniki i ustawienia w prostszej postaci. Martin Fowler zwraca uwagę na dodatkową złożoność i ostrożny dobór obszarów stosowania.

Kiedy wydzielić utrwalony model odczytu

Osobny kontrakt odczytu nie usuwa kosztu wykonywanego SQL. Jeśli raport regularnie liczy drogie agregacje na milionach wierszy, może potrzebować gotowej tabeli podsumowań. Masz wtedy kilka możliwości o różnych kosztach:

  1. Te same tabele i projekcja wykonywana przy zapytaniu. Najmniej mechanizmów do utrzymania; od tego warto zacząć.
  2. Osobna tabela odczytu aktualizowana w tej samej transakcji. Zapis jest droższy i zależy od schematu projekcji, ale obie zmiany mogą zatwierdzić się atomowo.
  3. Projekcja aktualizowana asynchronicznie. Worker utrzymuje tabelę, indeks wyszukiwarki lub osobny magazyn. Zyskujesz niezależność pracy odczytu, a przejmujesz opóźnienia i obsługę awarii synchronizacji.

Osobny magazyn ma sens, gdy potrzebujesz jego konkretnych możliwości: wyszukiwania pełnotekstowego, niezależnego skalowania, innego modelu indeksowania albo odizolowania ciężkich raportów. Dwie bazy tego samego typu, między którymi kopiujesz identyczne tabele, też generują koszt operacyjny. Ustal, co dokładnie ma go uzasadnić.

Asynchroniczna projekcja bez magazynu zdarzeń

Przy asynchronicznym wariancie nie wystarczy najpierw zapisać zamówienia, a następnie wysłać wiadomości do brokera. Awaria pomiędzy tymi krokami zostawi nowy stan bez aktualizacji modelu odczytu. Typowy przepływ z outboxem wygląda tak:

  1. Jedna transakcja zapisuje nowy stan w Orders i wiadomość w Outbox.
  2. Worker publikuje wiadomość i ponawia pracę po błędach.
  3. Projektor aktualizuje model odczytu i trwale zapisuje informację o przetworzeniu wiadomości.
  4. Ponowne dostarczenie tej samej wiadomości nie zmienia wyniku drugi raz.

Aktualizacja projekcji i zapis deduplikacji powinny być atomowe w jej magazynie albo zastąpione równoważnym mechanizmem warunkowego zapisu. Dla niezależnych zamówień kolejność globalna zwykle nie jest potrzebna; kolejność zmian jednego zamówienia już może być. Wersja obiektu pozwala odrzucić starszy pełny obraz stanu. Przy komunikatach zawierających tylko różnicę trzeba dodatkowo wykrywać brakujące wersje i zapewnić ich odtworzenie.

Outbox zapewnia trwałe zlecenie wysyłki, a projektor musi tolerować duplikaty i opóźnienia. Więcej o tej granicy znajdziesz w artykule Outbox Pattern — niezawodna komunikacja między serwisami. Nadal źródłem prawdy może być bieżący stan zamówienia w SQL.

Co użytkownik widzi zaraz po zapisie

Po udanej komendzie użytkownik wraca do listy i widzi stary status. Przy asynchronicznej projekcji jest to przewidywalna sytuacja, którą interfejs musi obsłużyć. Wybierz zachowanie odpowiednie dla operacji:

  • Pokaż wynik komendy. Potwierdzenie może od razu zawierać nowy status i wersję zamówienia.
  • Poczekaj na konkretną wersję projekcji. Odpytuj z ograniczonym czasem oczekiwania i pokaż stan „aktualizujemy widok”, jeśli projekcja nie nadąża.
  • Odczytaj krytyczne potwierdzenie z modelu zapisu. Taki wyjątek może być prostszy niż rozbudowana synchronizacja całego interfejsu.

Nie uzależniaj przyjęcia płatności, przydzielenia ostatniej sztuki ani kontroli limitu od opóźnionej projekcji. Decyzja musi zostać ponownie sprawdzona tam, gdzie zapis stanu jest autorytatywny. Przycisk „Dostępne” na ekranie jest informacją dla użytkownika; warunek przydziału zasobu wymaga ochrony w transakcji zapisu.

Nie obiecuj arbitralnie „spójności w sekundę”. Określ oczekiwane opóźnienie, zachowanie po jego przekroczeniu oraz sposób wykrycia awarii. Mierz wiek najstarszej nieprzetworzonej wiadomości, liczbę błędów i opóźnienie projekcji. Sama długość kolejki nie mówi, jak długo użytkownik czeka na zmianę.

Jak odbudować projekcję bez Event Sourcingu

Jeśli źródłem prawdy są aktualne tabele, model odczytu zwykle można zbudować ponownie przez ich przeskanowanie. Odtworzysz w ten sposób bieżący stan, lecz nie pełną historię zmian. Odpowiedź na pytanie „jak wyglądało zamówienie miesiąc temu?” wymaga osobno zaprojektowanej historii, audytu lub magazynu zdarzeń o odpowiedniej kompletności.

Odbudowa na działającym systemie ma dodatkowy problem: dane zmieniają się podczas kopiowania. Trzeba połączyć spójny punkt startowy z przechwyceniem zmian po tym punkcie, na przykład przez snapshot i mechanizm CDC z właściwą pozycją w logu. Alternatywą w mniejszym systemie bywa kontrolowana przerwa w zapisie. Samo wyczyszczenie projekcji i uruchomienie workera nie wystarczy, jeśli starsze komunikaty zostały już usunięte.

Dużą projekcję warto budować obok używanej wersji, sprawdzić jej kompletność, dogonić zmiany i dopiero przełączyć odczyt. Zaplanuj retencję wiadomości, wersjonowanie ich schematów oraz reakcję na błędne rekordy. Outbox opróżniany po wysyłce nie jest automatycznie archiwum do dowolnego odtwarzania.

Czym płacisz i co trzeba przetestować

Już przy jednej bazie utrzymujesz dwa kontrakty i pilnujesz zgodności znaczeń. Dodanie statusu zamówienia może wymagać zmiany agregatu, DTO, filtrów oraz prezentacji. Asynchroniczny wariant dokłada obsługę wiadomości, monitoring, plan odbudowy i procedurę naprawy rozbieżności.

  • Komendy: reguły biznesowe, uprawnienia, konflikty równoczesnych zapisów oraz ponowienie po utracie odpowiedzi.
  • Zapytania: izolacja klientów i firm, filtry, granice paginacji, zgodność kwot oraz plan zapytania na realistycznych danych.
  • Projekcje asynchroniczne: duplikaty, wiadomości poza kolejnością, restart po częściowej awarii, usunięcia danych i odbudowa pod obciążeniem.

Reguły agregatu można testować bez bazy. Tłumaczenie projekcji LINQ na SQL, zachowanie tokenu współbieżności i atomowość outboxa sprawdź na używanym silniku bazy. Mock handlera nie odtworzy tych właściwości. Dla asynchronicznego odczytu przetestuj również to, co zobaczy użytkownik podczas opóźnienia.

Jak zacząć w istniejącej aplikacji

Wybierz jeden ekran, który komplikuje model zapisu. Zdefiniuj jego DTO i wydziel zapytanie bez zmiany sposobu przechowywania zamówień. Następnie nazwij operacje zapisu zgodnie z intencją użytkownika i pozostaw reguły w modelu domenowym. Zmierz koszt zapytań oraz sprawdź, czy rozdzielenie rzeczywiście upraszcza pracę nad modułem.

Dopiero gdy pomiary pokażą potrzebę, dodaj utrwaloną projekcję albo osobny magazyn. Przed wariantem asynchronicznym ustal dopuszczalne opóźnienie, sposób dostarczania zmian i plan odbudowy. Event Sourcing rozważ wtedy, gdy sama historia zdarzeń ma być źródłem prawdy i potrafisz uzasadnić koszt jej utrzymania.

CQRS bez Event Sourcingu pozwala dopasować zapis do reguł biznesowych, a odczyt do potrzeb użytkownika. W wielu modułach wspólna baza oraz osobne modele wystarczą na długo.