Metoda Confirm agregatu Order w C#: sprawdzenie reguły i zdarzenie OrderConfirmed.

Zamówienie ma pozycje, rabaty i adres dostawy. Potem dochodzi rezerwacja towaru, płatność, faktura i wysyłka. Każda nowa funkcja trafia do Order, bo przecież „wszystko dotyczy zamówienia”. Po kilku miesiącach metoda Confirm() potrzebuje klienta HTTP, repozytorium magazynu i konfiguracji operatora płatności.

Aggregate Root nadal jest potrzebny. Problem leży w granicy, którą mu przypisaliśmy: próbuje pilnować całego procesu zakupowego. Żeby go odciążyć, trzeba rozdzielić reguły wymagające wspólnego zapisu od kroków, które mogą zakończyć się niezależnie.

Objawy agregatu, który robi za dużo

Liczba metod i długość pliku są słabym kryterium podziału. Rozbudowany cennik może mieć wiele reguł, które rzeczywiście należą do siebie. Bardziej niepokojące są sytuacje, w których niezwiązane operacje zaczynają sobie przeszkadzać:

  • Test zmiany adresu dostawy wymaga przygotowania płatności i stanu magazynu.
  • Dwóch użytkowników zmienia różne części zamówienia, ale regularnie wpadają na konflikt tej samej wersji.
  • Dodanie pozycji wymaga pobrania historii płatności, faktur i przesyłek.
  • Metody agregatu wywołują API innych systemów albo bezpośrednio wysyłają wiadomości.
  • Każda awaria operatora płatności blokuje również operacje, które nie potrzebują płatności.

Nie każdy z tych objawów oznacza nowy agregat. Wolny odczyt może wymagać osobnej projekcji, a rozbudowane obliczenie — wydzielenia polityki domenowej. Najpierw trzeba ustalić, która odpowiedzialność powoduje problem.

Granice wyznacza niezmiennik, nie tabela

Niezmiennik to reguła, która musi pozostać spełniona po zaakceptowaniu operacji. Dla zamówienia może to być zakaz złożenia pustego koszyka albo zmiany pozycji po jego złożeniu. Jeżeli operacja narusza taką regułę, agregat powinien ją odrzucić, zanim zapisze nowy stan.

Relacja w bazie nie rozstrzyga, czy obiekty należą do jednego agregatu. OrderLine może być encją wewnątrz Order, ponieważ jej zmiany podlegają regułom zamówienia. Payment może wskazywać OrderId i mieć osobny cykl życia: ponowienia, autoryzację, rozliczenie oraz zwrot. Klucz obcy łączy dane, ale nie tworzy jeszcze wspólnej granicy spójności.

Pytanie do biznesu

„Co dokładnie złego może się wydarzyć, jeśli te dwie zmiany nie zapiszą się razem?” jest bardziej użyteczne niż „czy możemy poczekać sekundę?”. Odpowiedź powinna opisywać niedozwolony stan, jego konsekwencje i sposób naprawy.

Akceptowane opóźnienie jest wskazówką, ale samo nie dowodzi, że potrzebujesz dwóch agregatów. Sprawdź również, czy mają osobne reguły, kto może je zmieniać i jak często konkurują o ten sam zapis. Agregaty wyznaczają granice spójności wewnątrz modelu; ich wydzielenie nie wymaga od razu osobnych mikroserwisów. Takie rozróżnienie opisuje przewodnik Microsoftu po taktycznym DDD.

Przykład: zamówienie, magazyn i płatność

Załóżmy, że sklep pozwala przyjąć zamówienie do realizacji, zanim magazyn potwierdzi dostępność. Klient widzi wtedy status „Oczekuje na rezerwację”. Potwierdzenie zamówienia oznacza dopiero, że towar został zarezerwowany, a płatność zaakceptowana. To przyjęta reguła tego przykładu — w innym sklepie kolejność może być inna.

Odpowiedzialności można rozdzielić następująco:

  • Order pilnuje pozycji, uzgodnionych cen i dozwolonych zmian statusu zamówienia. Po złożeniu blokuje edycję pozycji.
  • StockItem pilnuje dostępnej ilości konkretnego produktu w magazynie. Dwie rezerwacje ostatniej sztuki muszą konkurować o ten sam stan.
  • Payment pilnuje stanu konkretnej płatności, kwoty, waluty i identyfikatorów operacji operatora. Ponowiony komunikat nie może oznaczać ponownego pobrania pieniędzy.

Samo przeniesienie rezerwacji do klasy Reservation nie wystarczy. Jeżeli dwie niezależne rezerwacje sprawdzą tę samą dostępną sztukę, obie mogą ją przydzielić. Reguła dostępności musi mieć jeden punkt atomowego rozstrzygnięcia — w tym modelu jest nim zapis stanu StockItem.

Co zostaje w Aggregate Root

Poniżej fragment Order. Pokazuje wybrane reguły złożenia zamówienia; konstruktor, dodawanie pozycji i pozostałe walidacje są pominięte. Typy zdarzenia przedstawiają kopię danych potrzebnych do dalszego procesu.

public void Submit()
{
    if (Status != OrderStatus.Draft)
        throw new InvalidOperationException(
            "Zamówienie zostało już złożone.");

    if (_lines.Count == 0)
        throw new InvalidOperationException(
            "Zamówienie musi mieć pozycje.");

    if (_lines.Any(line => line.Quantity <= 0))
        throw new InvalidOperationException(
            "Ilość musi być dodatnia.");

    var requestedStock = _lines
        .Select(line => new RequestedStock(
            line.ProductId, line.Quantity))
        .ToArray();

    Status = OrderStatus.AwaitingStock;
    AddDomainEvent(new OrderSubmitted(Id, requestedStock));
}

public sealed record RequestedStock(Guid ProductId, int Quantity);
public sealed record OrderSubmitted(
    Guid OrderId, IReadOnlyList<RequestedStock> Lines);

AddDomainEvent dopisuje zdarzenie do kolekcji w pamięci. Nie wysyła żądania do magazynu ani nie publikuje do brokera. Warstwa aplikacji odbiera te zdarzenia i organizuje dalszą pracę. Rozróżnienie między zarejestrowaniem zdarzenia a jego obsługą jest opisane w dokumentacji zdarzeń domenowych.

Kto koordynuje pozostałe kroki

W prostym przypadku wystarczy handler w warstwie aplikacji. Gdy proces trwa dłużej, ma ponowienia i oczekuje na odpowiedzi, przydaje się jawny koordynator procesu, nazywany też process managerem. Przechowuje on postęp operacji i wybiera następny krok:

  1. Order przechodzi do AwaitingStock i rejestruje OrderSubmitted.
  2. Obsługa zdarzenia zleca rezerwację. Magazyn potwierdza ją albo odmawia.
  3. Po potwierdzeniu rezerwacji zamówienie przechodzi do AwaitingPayment, a koordynator zleca płatność.
  4. Potwierdzenie płatności pozwala zamówieniu przejść do Confirmed.
  5. Odmowa płatności kończy proces odrzuceniem zamówienia i zleceniem zwolnienia rezerwacji.

Koordynator nie zmienia statusu przez publiczny setter. Wywołuje metody agregatu, które sprawdzają dozwolone przejścia. Dla magazynu z wieloma produktami trzeba też określić, co zrobić z częściową rezerwacją: jeśli udało się zarezerwować dwa produkty z trzech, proces musi zwolnić wcześniej przydzielony towar albo jawnie obsłużyć częściową realizację.

Zdarzenie domenowe nie wymusza komunikacji asynchronicznej. W jednym procesie można obsłużyć je synchronicznie. Wybór momentu obsługi względem zatwierdzenia transakcji decyduje o tym, które zmiany mogą zapisać się atomowo. Microsoft omawia oba warianty i ich koszty; sam fakt użycia eventu nie rozwiązuje problemu spójności.

Kiedy wystarczy serwis domenowy

Nie każda odpowiedzialność wymaga własnej tożsamości, repozytorium i cyklu życia. Obliczenie rabatu na podstawie pozycji oraz przekazanej polityki cenowej może pozostać czystą operacją domenową:

public void ApplyDiscount(IDiscountPolicy policy)
{
    EnsureDraft();

    var discount = policy.Calculate(_lines.AsReadOnly());
    EnsureDiscountWithinOrderTotal(discount);

    Discount = discount;
}

To również fragment Order: polityka oblicza propozycję rabatu, a agregat nadal sprawdza, czy może ją przyjąć. W tym przykładzie polityka nie wykonuje operacji sieciowych. Jeśli potrzebuje danych z zewnętrznego systemu, warstwa aplikacji pobiera je wcześniej i przekazuje uzgodniony zestaw danych wejściowych.

Takie wydzielenie pomaga, gdy problemem jest złożony algorytm. Nie tworzy jednak nowej granicy transakcji. Jeśli stan magazynu ma własne reguły i wielu niezależnych użytkowników, przeniesienie jego kodu do OrderService nie usunie współdzielenia stanu ani konfliktów.

Po podziale trzeba obsłużyć awarie

Rozdzielenie agregatów zmniejsza zakres pojedynczej zmiany, ale dodaje stany pośrednie. „Zamówienie złożone, rezerwacja jeszcze niepotwierdzona” staje się normalnym stanem biznesowym. Powinien mieć nazwę, termin oczekiwania i widoczny dla użytkownika komunikat.

Zapis zdarzenia, ponowienia i kompensacja

Jeżeli najpierw zatwierdzisz zamówienie, a potem opublikujesz wiadomość, awaria między tymi krokami może zatrzymać proces bez śladu w kolejce. W Transactional Outbox zapisujesz wiadomość w tej samej transakcji co stan agregatu. Osobny proces publikuje ją do brokera i ponawia próbę po błędzie.

Publikacja może się powtórzyć, dlatego odbiorca musi rozpoznawać ponowione wiadomości. Identyfikator komunikatu lub operacji trzeba zapisać trwale, razem ze skutkiem jego obsługi. Sam HashSet w pamięci procesu nie ochroni przed restartem. Outbox nie zapewnia też automatycznie właściwej kolejności wszystkich zdarzeń ani jednokrotnego obciążenia karty.

Przy płatnościach potrzebny jest stabilny klucz idempotencji obsługiwany zgodnie z kontraktem operatora. Timeout oznacza nieznany wynik: operator mógł już przyjąć płatność. Zanim ponowisz obciążenie lub zwolnisz rezerwację, musisz uzgodnić stan tej samej operacji. Gdy płatność się udała, a zamówienie zostało już anulowane, proces potrzebuje jawnej ścieżki zwrotu albo obsługi ręcznej.

Zwolnienie rezerwacji czy zwrot płatności to kolejne operacje biznesowe. Mogą się nie udać i również wymagają ponowień. Więcej o zapisie i publikacji wiadomości jest w artykule Outbox Pattern — niezawodna komunikacja między serwisami.

Aggregate Root nie zastępuje kontroli współbieżności

Dwa żądania mogą pobrać tę samą wersję StockItem, zobaczyć jedną wolną sztukę i osobno przejść walidację. Poprawne metody domenowe nie wystarczą, jeżeli warstwa zapisu bezwarunkowo zaakceptuje oba wyniki.

Jednym z rozwiązań jest wersjonowanie optymistyczne. Aktualizacja sprawdza wersję odczytaną razem z agregatem. Jeśli inna operacja zdążyła ją zmienić, zapis jest odrzucany. W EF Core służą do tego tokeny współbieżności; konflikt może zakończyć się DbUpdateConcurrencyException, co opisuje dokumentacja obsługi konfliktów.

Po konflikcie trzeba ponownie odczytać stan i sprawdzić reguły. Nie wystarczy ponowić zapisu starych wartości. Mechanizm ochrony musi obejmować wszystkie zmiany istotne dla niezmiennika: jeśli wersja jest na korzeniu, zapis jego pozycji też powinien prowadzić do sprawdzenia i aktualizacji tej wersji. Zależnie od obciążenia i modelu danych można użyć również blokad lub atomowego, warunkowego zapisu w bazie.

Co przetestować przed zmianą granic

Same testy poprawnego przebiegu zakupów pokażą niewiele. Przy takim podziale sprawdziłbym co najmniej następujące scenariusze:

  • Reguły zamówienia: pustego zamówienia nie da się złożyć, a po złożeniu nie można zmienić pozycji. Odrzucona operacja nie dopisuje zdarzenia.
  • Konkurencja o ostatnią sztukę: dwa równoległe zapisy nie mogą zakończyć się dwiema udanymi rezerwacjami.
  • Duplikat komunikatu: ponowna obsługa tego samego potwierdzenia nie uruchamia drugiej płatności.
  • Restart po zapisie: wiadomość zapisana w outboxie zostaje wysłana także wtedy, gdy proces zakończy się tuż po zatwierdzeniu transakcji.
  • Częściowa rezerwacja i odmowa płatności: wcześniej przydzielony towar zostaje zwolniony, również po przejściowej awarii kompensacji.
  • Spóźniona odpowiedź: potwierdzenie płatności po anulowaniu zamówienia prowadzi do uzgodnionej obsługi, zamiast bezwarunkowo przywracać status Confirmed.

Reguły lokalne testujesz bez bazy i brokera. Atomowość outboxa, deduplikację oraz konflikty zapisu trzeba sprawdzić na rzeczywistej warstwie persystencji. Mock repozytorium nie odtworzy zachowania dwóch konkurujących transakcji.

Od czego zacząć w istniejącym systemie

Wybierz jedną operację, która regularnie boli — na przykład potwierdzenie zamówienia — i wypisz reguły, które musi spełnić. Przy każdej zapisz właściciela stanu, wymaganą spójność oraz zachowanie po błędzie. Dopiero wtedy zdecyduj, czy potrzebujesz nowego agregatu, polityki domenowej, czy osobnego procesu.

Podział można wdrożyć w monolicie i wspólnej bazie. Zacznij od jawnych metod, referencji przez identyfikatory i testów granic. Kolejkę oraz komunikację między usługami dodawaj wtedy, gdy potrzebujesz niezależnego wykonania i potrafisz obsłużyć jego konsekwencje.

Dobrym kryterium jest to, czy umiesz wskazać, kto chroni każdą regułę i co stanie się po przerwaniu procesu w dowolnym kroku. Mniejszy Order jest przydatnym efektem tej pracy. Najważniejsze, że odpowiedzialność za spójność przestaje być domysłem.