Modelowanie różnych stanów agregatu

1

W programowaniu funkcyjnym widziałem podejście, że zamiast mieć jeden ogólny typ domenowy mamy wiele bardziej wyspecjalizowanych typów zależnych od stanu. Czyli np. zamiast Order mamy NewOrder, SentOrder, VerifiedOrder, OrderInProgress, CompletedOrder itd. Jeśli chcielibyśmy coś podobnego zrobić w języku obiektowym, to mielibyśmy 5 klas (jedna klasa per status zamówienia) i każda z tych klas miałaby jakieś swoje unikalne metody. Oznacza to, że nie mielibyśmy logiki w stylu:

if (Status == OrderStatus.New) { ... modify order }

Zamiast tego mielibyśmy sprawdzanie, czy dana operacja jest dozwolona na poziomie systemu typów - np. jedynie NewOrder miałoby metodę pozwalająca dodać produkt. Pobieranie NewOrder mogłoby tak wyglądać:

NewOrder order = orderRepository.GetNewOrder(orderId); // rzuca wyjątek albo zwraca result z błędem jeśli zamówienie nie ma statusu "New" 

Mój problem polega na tym, że nie wiem, co zrobić w sytuacji, gdy dana operacja jest dozwolona na np. nie jednym, ale 2 statusach. Powiedzmy, że mogę anulować zamówienie, dopóki zamówienie nie zostanie zweryfikowane, czyli na statusach New i Sent. Najszybsze rozwiązanie, to żeby NewOrder i SentOrder miały jakiś interfejs, np. CancellableOrder i w tym interfejsie stworzyć jakąś domyślną metodę odpowiedzialną za anulowanie zamówienia.

Czy ktoś się spotkał z podobnym problemem? Myślę, że to jest dość ogólny problem, który nie dotyczy jedynie modelowania domeny.

0
nobody01 napisał(a):

W programowaniu funkcyjnym widziałem podejście, że zamiast mieć jeden ogólny typ domenowy mamy wiele bardziej wyspecjalizowanych typów zależnych od stanu. Czyli np. zamiast Order mamy NewOrder, SentOrder, VerifiedOrder, OrderInProgress, CompletedOrder itd. Jeśli chcielibyśmy coś podobnego zrobić w języku obiektowym, to mielibyśmy 5 klas

Strasznie dziwny pomysł (zaznaczam: nie jestem głęboko w programowaniu funkcyjnym).
Niektóre rzeczy wyglądają super w uniwersytetach na papierze, ale nie w boju.

Spójrz w tn sposób: że w/w gospodarkę zamówieniami ktoś ma konfigurować na runtime (tzn przekonfigurować, jest dostępne nie z dwóch, ale z trzech statusów).
Widzisz już nieżyciowość tego ? Dla mnie to wygląda jak pomysł zgarbionego naukowca w grubych okularach (tak filmowo)

0

Moim zdaniem manipulowanie maszyny stanów przez typy obiektów jest dość dziwaczne, dlaczego, jeżeli już upieramy się przy obiektowości, nie zrobić czegoś takiego:

public class Order{
  enum State{NEW, REALIZED, CANCELED}
  private State state = NEW;

  public cancelOrder(){
    if(state != NEW){
      throw new IllegalStateException();
    } else {
      ....
      state = CANCELED;
    }
  }
}

Problemem jest to, że w nieco bardziej skomplikowanych przypadkach robi się z całości drabinka if'ów. Kiedyś zdarzyło mi się popełnić taką "bibliotekę": https://github.com/piotrpo/SimpleFsm żeby dało się sensownie przepisać diagram stanów do kodu.

3

Myślę że jest tutaj pewne nieporozumienie. Przede wszystkim to nie ma nic wspólnego z programowaniem funkcyjnym per se. Akurat trafiłeś na przykład takiego podejścia napisany funkcyjnie, ale równie dobrze mógłbyś się na nie natknąć napisane w OOP.

Mowa o podejściu gdzie agregaty modeluje się tak aby były relatywnie małe, i bardziej skupiały się na logicznym objęciu samej transakcji/procesie niż obiekcie świata rzeczywistego. Wiem że to może brzmieć zbyt abstrakcyjnie, więc podam prawdziwy przykład z systemu nad którym pracowałem w poprzedniej pracy. Notabene był to i nadal jest projekt gdzie do tej pory widziałem najlepiej zaimplementowane DDD, zarówno w kodzie jak i w zespołowej komunikacji, procesach itp. Mieliśmy wszystko "zgodnie ze sztuką", w tym ekspertów domenowych, event storming itp. Jeśli czegoś mi brakuje z tamtej pracy, to właśnie tego.

Mowa o dużym systemie ubezpieczeniowym dla agentów, a więc ludzi sprzedających i obsługujących polisy dla klientów. No i właśnie- polisy. Pierwsze co przychodzi na myśl człowiekowi myśląc o ubezpieczeniu. Jest więc kuszące aby pomyśleć że jednym z podstawowych i najważniejszych agregatów będzie właśnie polisa, ang. policy. Rzecz w tym że szybko odkryliśmy, że agenci rzadziej mówią o polisach w kontekście czegoś co sprzedają/zmieniają, a raczej mówią co dokładnie zrobili. Przykłady:

Agent nie mówił I sold a policy tylko I placed a new business (policy) with customer.
Agent nie mówił I updated a policy tylko I placed a mid-term adjustment on the policy.
Agent nie mówił I renewed a policy tylko *I placed a renewal.

Przypominam, że stosowaliśmy DDD, a więc odpowiednie nazewcnitwo było kluczowe. To nie my (programiści) mieliśmy dyktować jak rzeczy mają być nazywane, od tego byli użytkownicy systemu i eksperci domenowi. Szybko uświadomiliśmy sobie, że polisa jest pochodną procesów biznesowych, widokiem z którego agent korzysta kiedy chce rozpocząć nowy proces. A ponieważ stosowaliśmy również event sourcing i CQRS, to wiedzieliśmy gdzie jest miejsce takiej pochodnej- w modelu widoku (read side) a nie w modelu domenowym (write side). Co za tym idzie, agregaty jakie mieliśmy w systemie to między innymi:

  • NewBusiness
  • MidTermAdjustment
  • Cancellation
  • Renewal

Każdy agregat miał inne niezmienniki (zasady biznesowe) które egzekwował, a dzięki temu że były tak podzielone, to granica transakcyjności była jasna. Jeśli danym procesem był zmiana czegoś w polisie (mid-term adjustment), to dla czego w tym samym agregacie miałyby istnieć zasady dotyczące przerwania polisy (cancellation)? Ewidentnie to dwa różne procesy, a więc dwie różne transakcje.

To wszystko nie oznacza że takie podejście jest jedynym słusznym. Owszem, można mieć system gdzie bardziej skupiamy się na przedmiotach rzeczywistych (policy, student), zamiast procesach (new business, student enrolment), ale moim zdaniem kiedy dobrze zrozumienie się to co opisałem wyżej, to łatwiej modelować i utrzymywać system w oparciu o mniejsze agregaty zorientowane na procesy. Poza tym idzie to w parze z ogólną rekomendacją aby projektować w miarę możliwości mniejsze agregaty.

Myślę że- paradoksalnie- łatwiej jest takie rzeczy ogarnąć kiedy stosuje się DDD razem z CQRS. Wtedy jest jasny podział między stroną obsługi procesów biznesowych (write) od strony widoków (read), więc jest mniejsza pokusa by myśleć o agregatach w kategorii zarówno modelu domeny jak i tego co widać w interfejsie użytkownika. Zresztą nie bez powodu kiedy mowa o DDD (a więc i agregatach) to często w parze idą CQRS i event sourcing- te wszystkie techniki świetnie się uzupełniają.

Vernon sporo o tym pisał, dostępne tutaj.
Tutaj też bardzo fajna prezentacja na ten temat- all our aggregates are wrong. Autro w pewnym momencie- używając właśnie zamówień online jako przykładu- mówi pewne kluczowe kwestie które powinny dać do myślenia:

  • We are modeling behavior, not data
  • The shopping cart doesn't exist
0

@Aventus: Samą ideę wziąłem z tej prezentacji oraz z tego repozytorium.

To podejście, o którym piszesz jest bardzo ciekwawe i wydaje się być bardzo rozszerzalne. Z tego, co pamiętam, to budowaliście te agregaty z eventów pochodzących z jednego źródła i do tego samego źródła były też zapisywane nowe eventy. Miało takie podejście jakieś wady?

Wracając do przykładu z zamówieniami. Widzę takie agregaty:

  1. OrderStart
  2. OrderPlacement
  3. OrderVerification
  4. OrderCompletion
  5. OrderCancellation -> tego mi brakowało w moim przykładzie

Mam takie pytanie: powiedzmy, że użytkownik ma mieć możliwość zmiany ilości produktów w zamówieniu (status New). Stworzyłbyś osobny aggregat do tej operacji, np. ProductQuantityChange emitujący event ProductQuantityChanged?

1

Z tego, co pamiętam, to budowaliście te agregaty z eventów pochodzących z jednego źródła i do tego samego źródła były też zapisywane nowe eventy. Miało takie podejście jakieś wady?

To nie do końca było tak. Każdy agregat miał swój event stream (to idea z event sourcingu oczywiście). Nie pamiętam już dokładnie, ale były tam specjalne projekcje które pozwalały zasysać eventy z innych streamów, do streamów innych agregatów. Nie jest to podejście które sam bym zastosował, ale tam to tak działało jeśli dobrze pamiętam.

Mam takie pytanie: powiedzmy, że użytkownik ma mieć możliwość zmiany ilości produktów w zamówieniu (status New). Stworzyłbyś osobny aggregat do tej operacji, np. ProductQuantityChange emitujący event ProductQuantityChanged?

Prawdopodobnie tak, chociaż wydaje mi się, że w takim przypadku mowa o bardziej szeroko pojętym order adjustment, a więc miałbym agregat o takiej właśnie nazwie a nie typowo pod zmianę jednego z atrybutów zamówienia (ilość produktów).

0
Aventus napisał(a):

Nie jest to podejście które sam bym zastosował, ale tam to tak działało jeśli dobrze pamiętam.

A jakie podejście byś zastosował teraz?

2

O, widzę że kolejna sesja modelowania ;)

nie wiem, coś tam poklepałem z założeniami aby

1: wynieść przechodzenie pomiędzy stanami z obiektów domenowych gdzieś indziej

2: mieć jedno miejsce sterujące logiką przejść

3: uniknąć jednej klasy która ma wszystkie property do 5 różnych stanów

I w sumie trochę type system mnie nie puścił aby mieć do tego jedną metodkę typu MoveNext(). Kiedyś jak dobrze pamiętam mając jedną klasę od zawierającą propertisy i jeden input, to udało się taką metodkę strzelić :D

Ale brakuje mi tu jakiejś logiki w tych Orderach, takie jakieś to zbyt anemiczne?

Poszedłem tu w takim kierunku że gdy Order zmienia stan z A na B, to obiekt na stanie A dostaje takiego "soft delete" (IsActive = false)

I są dwa Idki - jeden to jest jakby Id całego procesu, a zatem każdy kolejny obiekt tworzony na podstawie poprzedniego będzie go ciągnął za sobą

Oraz Id techniczny, każdy obiekt ma swój.

public record NewOrder
{
    public Guid TechnicalId { get; } = Guid.NewGuid();
    public Guid Id { get; } = Guid.NewGuid(); // // Id Procesu, będzie z nami aż do ostatniego stanu 
    public DateTime CreatedAt { get; } = DateTime.UtcNow;
    public bool IsActive { get; set; } = true;
}

public record AcceptedOrder(Guid Id, string Description, DateTime AcceptedAt)
{
    public Guid TechnicalId { get; } = Guid.NewGuid();
    public bool IsActive { get; set; } = true;
}

public record CompletedOrder(Guid Id, string InvoiceNumber, DateTime AcceptedAt)
{
    public Guid TechnicalId { get; } = Guid.NewGuid();
    public bool IsActive { get; set; } = true;
}

public class OrderStateMachineTransitioner
{
    private readonly Bazka _bazka;

    public OrderStateMachineTransitioner(Bazka bazka)
    {
        _bazka = bazka;
    }

    public TransitionResult<AcceptedOrder> FromNewToAccepted(NewOrder newOrder, string description)
    {
        var result = ValidateDescription(newOrder, description);

        if (result.Failed)
            return new TransitionResult<AcceptedOrder>(result.Message);

        var order = new AcceptedOrder(newOrder.Id, description, DateTime.UtcNow);
        _bazka.MakeInactive(newOrder.TechnicalId);
        _bazka.Save(order);
        return new TransitionResult<AcceptedOrder>(order);
    }

    public TransitionResult<CompletedOrder> FromNewToCompleted(NewOrder newOrder, string invoice)
    {
        var result = ValidateInvoice(newOrder, invoice);

        if (result.Failed)
            return new TransitionResult<CompletedOrder>(result.Message);

        var order = new CompletedOrder(newOrder.Id, invoice, DateTime.UtcNow);
        _bazka.MakeInactive(newOrder.TechnicalId);
        _bazka.Save(order);
        return new TransitionResult<CompletedOrder>(order);
    }

    public TransitionResult<CompletedOrder> FromAcceptedToCompleted(AcceptedOrder acceptedOrder, string invoice)
    {
        var result = ValidateInvoice(acceptedOrder, invoice);

        if (result.Failed)
            return new TransitionResult<CompletedOrder>(result.Message);

        var order = new CompletedOrder(acceptedOrder.Id, invoice, DateTime.UtcNow);
        _bazka.MakeInactive(acceptedOrder.TechnicalId);
        _bazka.Save(order);
        return new TransitionResult<CompletedOrder>(order);
    }

    private Result ValidateDescription(NewOrder acceptedOrder, string description)
    {
        if (string.IsNullOrEmpty(description))
            return Result.Fail("Description is invalid.");

        return Result.Ok();
    }

    private Result ValidateInvoice(AcceptedOrder acceptedOrder, string invoice)
    {
        if (string.IsNullOrEmpty(invoice) || !_bazka.InvoiceExists(invoice))
            return Result.Fail("Invoice not found");

        return Result.Ok();
    }

    private Result ValidateInvoice(NewOrder acceptedOrder, string invoice)
    {
        if (string.IsNullOrEmpty(invoice) || !_bazka.InvoiceExists(invoice))
            return Result.Fail("Invoice not found");

        return Result.Ok();
    }
}
1

@nobody01:

A jakie podejście byś zastosował teraz?

Przede wszystkim stream agregatu miałby tylko eventy które ten agregat sam opublikował. Jeśli byłaby potrzeba użycia jakichś zewnętrznych danych, to uzylbym do tego serwisu domenowego. My serwisów domenowych w ogóle nie mieliśmy, wszystko było w agregatach.

0

@Aventus: To może dla ustalenia uwagi załóżmy, że mamy proces anulowania zamówienia. Chciałbym się upewnić, czy dobrze rozumiem. Co poprawiłbyś w tym kodzie?

// warstwa aplikacji
void CancelOrder(CancelOrder command)
{
  OrderCancellation orderCancellation = orderCancellationRepository.GetByOrderId(comman.OrderId);
  orderCancellation.Handle(new Domain.CancelOrder());
  orderCancellationRepository.Save(orderCancellation);
}

// repository
OrderCancellation GetByOrderId(int orderId)
{
  var events = eventService
    .WithCorrelationId(orderId)
    .WithEventsFrom<OrderStart>()
    .WithEventsFrom<OrderPlacement>()
    .WithEventsFrom<OrderCancellation>()
    .Get(); // eventy uporządkowane od najstarszego

  return events.Aggregate(new OrderCancellation(), (orderCancellation, @event) => orderCancellation.Rehydrate(@event));
}

class OrderCancellation : Entity
{
  public Guid OrderId { get; private set; }
  public OrderStatus OrderStatus { get; private set; } // todo: żeby mieć właściwy status zamówienia musielibyśmy odtworzyć
    // wszystkie eventy związane ze zmianą statusu. Więc może jakaś właściwość IsCancellable w zamian?
    // Wtedy wystarczyloby odtworzyc jedynie kilka pierwszych eventów związanych ze zmianą statusu.

  public OrderCancellation Rehydrate(IEvent @event) { ... }

  void Handle(CancelOrder command)
  {
      CheckStatus();
      events.Add(new OrderCancelled(OrderId);
  }
}
1

Przede wszystkim to co bym poprawił, to nie ładował bym wszystkich eventów ze streamów innych agregatów, aby zbudować stan agregatu OrderCacellation. Moim zdaniem rozdzielenie agregatów mija się wtedy z celem.

Odpowiedziałbym na pytanie: czy zależy mi na całkowitej transakcyjnej spójności, czy też nie?

Jeśli tak, to stworzyłbym jakiś lookup który na podstawie tych poprzednich eventów z innych agregatów, ustaliłby obecny status zamówienia, i ten status przekazał do agregatu OrderCancellation. Ten lookup użyłbym albo w serwisie domenowym (ewentualnie w serwisie aplikacji którego przykład podałeś wyżej), albo bezpośrednio w agregacie jeśli takie rozwiązanie Ci pasuje (mi akurat nie, więcej o tym pisałem tutaj jeśli dobrze pamiętam). Chodzi o to że agregat używałby tego statusu nie jako swojego stanu, a jako parametru wejściowego dla obsłużenia command.

Jeśli zaś na pełnej transakcyjności by mi nie zależało, to użyłbym jako lookup projekcji, biorąc na klatę eventual consistency i wszystkiego co się z tym wiąże.

0

@Aventus: Naniosłem zmiany i teraz kod wygląda tak:

// warstwa aplikacji
void CancelOrder(CancelOrder command)
{
  OrderLookup orderLookup = orderLookupService.GetById(command.OrderId);
  OrderCancellation orderCancellation = new OrderCancellation(orderLookup.OrderId, orderLookup.OrderStatus);
  orderCancellationRepository.Add(orderCancellation);
}

class OrderCancellation : Entity
{
  public Guid OrderId { get; private set; }

  public OrderCancellation(Guid orderId, OrderStatus orderStatus)
  {
      CheckOrderStatus(orderStatus);
      OrderId = orderId;
      events.Add(new OrderCancelled(orderId);
  }
}

Pytanie: co rozumiesz przez lookup? Czy lookup powinien pobrać te wszystkie eventy tak jak w poprzednim fragmencie kodu i zbudować projekcję?

OrderLookup GetByOrderId(int orderId)
{
  var events = eventService
    .WithCorrelationId(orderId)
    .WithEventsFrom<OrderStart>()
    .WithEventsFrom<OrderPlacement>()
    .WithEventsFrom<OrderCancellation>()
    .Get(); // eventy uporządkowane od najstarszego

  return events.Aggregate(new OrderLookup(), (orderLookup, @event) => orderLookup.Rehydrate(@event));
}
1

Pytanie: co rozumiesz przez lookup? Czy lookup powinien pobrać te wszystkie eventy tak jak w poprzednim fragmencie kodu i zbudować projekcję?

Tak, o to mi chodziło. W tym przypadku projekcja może składać się z jednej wartości- statusu zamówienia.

Jeśli chodzi o Twój przykład z agregatem to zmierza to w dobrym kierunku, natomiast sam fakt utworzenia instancji agregatu nie powinien być równoznaczny z anulowanym zamówieniem. Innymi słowy- konstruktor nie powinien publikować tego eventu, a agregat powinien mieć metodę przyjmującą command lub parametry związane z anulowaniem.

Myślę że takie problemy tworzą się ze zbyt prostych przykładów. W prawdziwej domenie związanej z zamówieniami wszystko wynikało by naturalnie. Notabene zauważyłem że jest to pewien paradoks związany z DDD- z jednej strony człowiek chce się tego nauczyć i należałoby dobrze znać DDD zanim się próbuje pisać w nim systemy produkcyjne, ale z drugiej ciężko się uczyć DDD nie mając realnej domeny. Realnej nie w sensie jakiś przykład odzwierciedlający świat rzeczywisty, a ta konkretna domena, z tą konkretną firmą i z tymi konkretnymi zasadami i problemami.

0
Aventus napisał(a):

Jeśli chodzi o Twój przykład z agregatem to zmierza to w dobrym kierunku, natomiast sam fakt utworzenia instancji agregatu nie powinien być równoznaczny z anulowanym zamówieniem. Innymi słowy- konstruktor nie powinien publikować tego eventu, a agregat powinien mieć metodę przyjmującą command lub parametry związane z anulowaniem.

A dlaczego nie powinniśmy publikować eventów w konstruktorze? Widziałem takie podejście tu: https://codeopinion.com/cqrs-event-sourcing-code-walk-through/ (agregat InventoryItem)

1

Chodziło mi o to, że w realnym systemie zapewne na proces anulowania złożyło by się trochę więcej, więc taki agregat musiałby przyjmować więcej niż jeden command. Natomiast osobiście sam tego bym nie zrobił z jednego prostego powodu- spójność konwencji. Jeśli mam N agregatów przyjmujących różne commands i emitujące różne eventy, to nie chciałbym wprowadzić czegoś wbrew konwencji, co mogłoby wprowadzić niepotrzebną konsternację. Inna sprawa jeśli masz konwencję w której każdy agregat, zawsze emituje jakiś event w konstruktorze (Created, Enrolled, Registered etc.), wtedy oczywiście nie będziesz robił czegoś wbrew konwencjom które stosujesz.

Chcę na marginesie podkreślić że za każdym razem kiedy piszę że czegoś nie należy/nie powinno się/etc robić, to bierz na to poprawkę. W programowaniu nie ma nienaruszalnych reguł których nigdy, pod żadnym pozorem nie można łamać. Na każdą regułę znajdzie się wyjątek, najczęściej z całkowicie nieprzewidzianego powodu. Każdy kto twierdzi inaczej jest początkującym w danym temacie i/lub zaślepiony dogmatami.

1 użytkowników online, w tym zalogowanych: 0, gości: 1