CQRS i mikroserwisy: zapis danych

Ostatnimi czasy na blogu było dosyć cicho, ale wynika to z dwóch rzeczy. Po pierwsze zrobiłem sobię przerwę wakacyjną (która przyznam szczerze rozciągnęła się nieco w czasie), a po drugie aplikacja DShop zmieniła się mocno pod kontem infrastrukturalnym, dlatego pozwoliłem sobie wstrzymać serię o mikroseriwach, aby nie doszło do sytuacji, w której prezentowany w poście kod staje się po chwili nieaktualny. Tyle słowem wstępu.

W jednym z ostatnich wpisów przedstawiłem czym jest, a czym nie jest wzorzec CQRS, czytaj Command Query Responsibility Segragation. Zrobiłem to nie bez powodu. Gdybym w aplikacji DShop miał wskazać jeden wzorzec/podejście, dzięki któremu kod jak i jego ogólna organizacja stały się lepsze to bez wahania wskazałbym własnie CQS/CQRS. W związku z tym drogi czytelniku, postaram się pokazać Ci w jaki sposób możesz ów wzorzec wpleść do rozproszonego świata mikro usług. Dziś omówię zapis danych.

 

 

Ogólny zarys podejścia

Przed przedstawieniem kodu warto, abyś zapoznał się z ogólnym zarysem podejścia, tak aby w późniejszej części wpisu wszystko było dla Ciebie jasne. Poniżej zamieściłem proty diagram, który używany był podczas prezentacji „Distributed .NET Core”. Dotyczy on zapisu danych w aplikacji DShop:

 

 

Omówmy pokrótce całe flow. Wszystko rozpoczyna się oczywiście od żądania HTTP, które zostaje odebrane przez API Gateway. Ów żądanie zawiera (w body) komendę – intencję użytkownika aplikacji przekazaną w formie obiektu. Warto zaznaczyć, że komenda nie jest tożsama z DTO (ang. Data Transfer Object). Różnica w moim odczuciu leży bardziej w sferze ideologicznej niż we właściwej ich implementacji. Otóż DTO jest z definicji „data centric”. Oznacza to, że głównym celem istnienia tych obiektów jest przekazywanie danych jednak bez nadania im głębszego (domenowego) kontekstu. Są to po prostu „głupie” pojemniki na informacje. Komendy są zaś „behavior centric” co oznacza, że ich celem jest wykonanie jakiejś akcji, a dane w nich umieszczone mają tylko pomóc w ich wykonaniu. Stąd też przyjęło się, że nazwy komend piszemy w trybie rozkazującym np. CreateProduct, RejectOrder, CheckAvailableFlights itd. Jak widzisz sama nazwa już podpowiada programiście czego może spodziewać się w systemie po przetworzeniu komendy.
Wracając do diagramu, po odebraniu komendy zostaje ona „wrzucona” na kolejkę przez API, a do klienta zostaje wysłana odpowiedź HTTP ze statusem 202 – Accepted. Zwróć  proszę na to uwagę – status informuje, że żądanie zostało jedynie zaakceptowane. Nie oznacza jednak, że operacja się powiodła lub nie powiodła. Na tym etapie nie mamy takiej wiedzy ponieważ:

  1. Zapis danych realizowany jest w pełni asynchronicznie. Komenda może zostać obsłużona chwilę po tym jak znalazła się w kolejce, ale równie dobrze dopiero 20 min później. Ponieważ nie chcemy blokować użytkownika, zwracamy informację, że przyjęliśmy zgłoszenie i tyle.
  2. Zgodnie z tym co pisałem w poprzedniej części o CQRS, zmiana stanu aplikacji oznacza, że metoda nic nie zwraca (void lub Task). W związku z tym nawet gdybyśmy dokonywali synchronicznej obsługi komendy, to dalej nie bylibyśmy w stanie stwierdzić czy operacja się powiodła.

Rozwiązanie tego problemu tj. informowania użytkownika o statusie jego operacji pozwolę sobie opisać w osobnym wpisie w niedalekiej przyszłości. Wracając do diagramu, gdy komenda znajduje się już w kolejce, należy ją „zdjąć” i przetworzyć. Jak widzisz metoda komunikacji poprzez RabbitMQ odbywa się w trybie publish-subscribe. Oznacza to, że kolejne mikroseriwsy subskrybują się podczas uruchomienia na konkretne komendy, które będą potrafiły obsłużyć (np. usługa odpowiedzialna za zarządzanie zamówieniami będzie subskrybować się wyłącznie pod komendy skorelowane właśnie z tym wycinkiem domeny, a nie np. z zarządzaniem kontrahentami). Zatem, gdy komenda zostaje opublikowana na kolejce, usługa która się pod nią subskrybowała otrzymuje obiekt i rozpoczyna jego przetwarzanie. Obiekt komendy zostaje przekazany do odpowiedniego command handlera. Najprościej ujmując, command handler to klasa, która wie co zrobić z konkretną komendą – potrafi zinterpretować obiektową intencję użytkownika aplikacji w postaci wywołania konkretnej logiki biznesowej. Przykładowo command handler dla komendy CreateProduct… uwtorzy nowy produkt wykorzystując zawarte w komendzie dane jak nazwa produktu, cena itp. Oczywiście modyfikacja stanu naszej domeny musi ulec utrwaleniu poprzez zapis do bazy danych. W tym miejscu warto wspomnieć, że każda usługa posiada własną, niezależną bazę danych, która przetrzymuje jedynie informacje istotne lda konkretnej usługi. Plusem takiego podejścia jest to, że możemy dobierać bazę danych do konkretnych części naszego systemu, a nie z góry zakładać, że wszystko będzie działać na np. SQL Server 2016. Nie każdy fragment domeny łatwo mapuje się na relacyjny model danych, a usilne próbowanie może doprowadzić albo do skomplikowanego kodu albo do problemów wydajnościowych.

Jak widzisz sam zapis nie jest nadto skomplikowany. W gwoli podsumowania wypiszę skrócony opis całego flow:

  1. Odebrane zostaje żądanie HTTP z komendą
  2. Komenda zostaje opublikowana na kolejce (asynchronicznie)
  3. Zwrócona zostaje odpowiedź HTTP ze statusem 202- Accepted
  4. Usługa, która subskrybowała się pod konkretną komendą otrzymuje obiekt
  5. Komenda zostaje przekazana do klasy, która wie jak ją obsłużyć – command handlera
  6. Command handler wywołuje odpowiednią logikę biznesową
  7. Command handler poprzez repozytorium dokonuje modyfikacji danych (CUD)

Drobna uwaga:

Jeżeli byłeś/aś na prezentacji „Distributed .NET Core” to być może widzisz, że przedstawiony powyżej diagram różni się nieco od tego prezentowanego na żywo. W oryginalnej wersji pomiędzy command handlerem, a repozytorium znajdował się jeszcze domain service (który tak na prawdę był bardziej serwisem aplikacyjnym). Po naradach z Piotrkiem postanowiliśmy z nich zrezygnować, ponieważ nie wnosiły one nic do projektu, a nawet nieco utrudniały nam życie. Kod z serwisów w całości został przeniesiony do command handlerów.

 

Implementacja API Gateway

Po części teoretycznej pozwolę sobie przejść przez kolejne składowe implementacji zapisu danych. Przykładem, którym się posłużę będzie wcześniej wspomniane stworzenie nowego produktu. Poniżej komenda:

 


//Marker
public interface ICommand
{
}


public class CreateProduct : ICommand
{
    public Guid Id { get; }
    public string Name { get; }
    public string Description { get; }
    public string Vendor { get; }
    public decimal Price { get; }
    
    [JsonConstructor]
    public CreateProduct(Guid id, string name, string description, string vendor, decimal price)
    {
        Id = id;
        Name = name;
        Description = description;
        Vendor = vendor;
        Price = price;
    }
}

 

Zwróć proszę uwagę, że obiekt komendy jest immutable (nie zawiera jawnych setterów). Powód jest bardzo prosty. Jeżeli użytkownik przesyła nam swoją intencję w postaci obiektowej to nie chcemy, aby cokolwiek ją po drodze zmieniło. W DShop pojawiło się małe oszustwo, które łamie tą regułę, ale do tego dojdziemy za chwilę 😉
Warto jeszcze wspomnieć gdzie składowane są komendy. Jeżeli czytałeś wpis „O repozytoriach i strukturze projektów w aplikacji opartej o mikroserwisy” to zapewne pamiętasz, że prezentowałem w nim dedykowany projekt (DShop.Messages), który zawierał wszystkie kontrakty i do którego referowały wszystkie usługi + API. Jednakże pod wpływem kilku, trafnych uwag zdecydowaliśmy się zmienić nieco organizację kontraktów. Na dzień dzisiejszy każda mikro usługa posiada lokalne implementacje tych kontraktów (komend oraz eventów), których używa, a API Gateway swoje lokalne wersje wszystkich komend. Jeżeli jest to mało klarowne to poniższy screen z VS powinien rozwiać wszelkie wątpliwości:

 

 

Podejście z pozoru może nie wydawać się sensowne, ponieważ ewentualnych zmian musimy dokonywać w dwóch miejscach (co oznacza, że łatwiej o wszelkiej maści „rozjazdy”), ale posiada także wiele plusów. Jeżeli chcesz je poznać, serdecznie zapraszam do przeczytania bardzo ciekawej dyskusji na ten temat (sekcja komentarze).
Przejdźmy dalej i zobaczmy implementację akcji w API Gateway, która odbierze komendę i opublikuje ją na kolejkę:

 


[HttpPost]
public async Task<IActionResult> Post(CreateProduct command)
    => await SendAsync(command.BindId(c => c.Id), 
        resourceId: command.Id, resource: "products");


Jak widzisz jest to standardowy zapis akcji w ASP.NET Core. Zanim przejdziemy do omawiania i prezentacji kodu metody SendAsync, chciałbym zwrócić Twoją uwagę na inny zapis, a mianowicie metodę BindId. Jest to wspomniane wcześniej małe oszustwo, które łamie immutability komend, ale zostało wprowadzone w słusznej sprawie 😉 Już śpieszę wyjaśniać. Jak zapewne widzisz, jedną z właściwości CreateProduct jest identyfikator produktu, który ma zostać dodany do systemu. Z racji tego, że obiekt ten jest wysyłany z aplikacji klienckiej moglibyśmy zrzucić odpowiedzialność generowania GUID-a na frontend, ale… jeżeli kiedykolwiek pisałeś w JS wiesz zapewne, że nie jest to tak proste jak w C#. Stąd też dodana została metoda rozszerzająca o nazwie BindId, która po wskazaniu właściwości w selektorze (mowa o zapisie c => c.Id) generuje nowy GUID i używając refleksji przypisuje go do wskazanej właściwości mimo iż nie posiada on jawnego settera. Dzięki temu w żądaniu HTTP nie musi znajdować się Id, a komenda w C# mimo wszystko będzie go posiadać. Oprócz metody BindId w kontrolerach DShop możesz także napotkać bliźniaczą metodę o nazwie Bind. Pozwala ona na przypisanie do wskazanej właściwości komendy, dowolnej wartość (zgodnej z typem). Tu także działanie jest uzasadnione np. gdy:

  • jedną z właściwości komendy jest np. UserId, które i tak zawarte jest w JWT i nie chcemy, aby frontend wysyłał je ponownie w request body
  • część danych jest przekazana w ścieżce żądania (np. przy PUT) i nie chcemy , aby wysyłać to samo w request body

Przykład drugiego użycia przedstawia listing poniżej:

 


[HttpPut("{id}")]
public async Task<IActionResult>  Put(Guid id, UpdateProduct command)
    => await SendAsync(command.Bind(c => c.Id, id), 
        resourceId: command.Id, resource: "products");

 

Ktoś mógłby w tym momencie powiedzieć, że jest to przerost formy nad treścią i łamie zasady „dobrego” programowania. Można przecież stworzyć DTO, które odbierze API, a same komendy tworzyć dopiero w kontrolerach. Jest to oczywiście prawda, ale osobiście uważam, że im mniej modeli do utrzymania tym lepiej. Powyższy kod jest pewnie zaliczany jako „smell”, ale w tym konkretnym przypadku uważam, że jest o niebo wygodniejszy, niż miliony DTO-sów, które co chwile trzeba dopisywać, aby zrobić prostą akcję w MVC.

Po tym krótkim wywodzie, przejdźmy do wspomnianej akcji SendAsync, która to znajduje się ona w kontrolerze bazowym:

 


protected async Task<IActionResult> SendAsync<T>(T command, 
    Guid? resourceId = null, string resource = "") where T : ICommand
{
    var context = GetContext<T>(resourceId, resource);
    await _busPublisher.SendAsync(command, context);

    return Accepted(context);
}


 

Na dzień dzisiejszy nie będzie interesowało nas konkretne użycie i zastosowanie obiektu context, ponieważ jest on powiązany z mechanizmem komunikowania użytkownika o statusie operacji (dojdziemy do tego wkrótce). _busPublisher to nic innego jak opakowany klient biblioteki RawRabbit, która umożliwia podłączenie się do RabbitMQ z poziomu kodu C#. Wybrana została głównie dlatego, że obsługuje netstandard2.0. Implementacja klasy BusPublisher wygląda następująco:

 


internal class BusPublisher : IBusPublisher
{
    private readonly IBusClient _busClient;

    public BusPublisher(IBusClient busClient)
    {
        _busClient = busClient;
    }

    public async Task SendAsync<TCommand>(TCommand command, ICorrelationContext context) 
        where TCommand : ICommand
        => await _busClient.PublishAsync(command, ctx => ctx.UseMessageContext(context));

    public async Task PublishAsync<TEvent>(TEvent @event, ICorrelationContext context) 
        where TEvent : IEvent
        => await _busClient.PublishAsync(@event, ctx => ctx.UseMessageContext(context));
}

 

Na tym etapie omówiony kod wykonuje 3 z 7  kroków całego flow tj.:

  1. Akcja w kontrolerze ASP.NET Core odbiera komendę CreateProduct i wywołuje metodę SendAsync
  2. Metoda SendAsync poprzez obiekt _busPublisher publikuje komendę na kolejce
  3. Metoda SendAsync zwraca do użytkownika odpowiedź HTTP z kodem 202 (Accepted)

 

Na tym kończy się udział API Gateway. Pora przejść do właściwej usługi, która odbierze komendę CreateProduct i wykona odpowiednią logikę biznesową.

 

Implementacja usługi

Cały proces przetwarzania komendy CreateProduct  po stronie mikroserwisu rozpoczyna się oczywiście od jej odebrania. Żeby jednak było to możliwe (i zgodne z tym co prezentował diagram) usługa uprzednio powinna się w jakiś sposób do niej zasubskrybować. Kod za to odpowiedzialny znajdziesz w klasie Startup:

 


 app.UseRabbitMq()
     .SubscribeCommand<CreateProduct>()
     .SubscribeCommand<UpdateProduct>()
     .SubscribeCommand<DeleteProduct>();


 

Jak się zapewne domyślasz, nie jest to kod dostarczony przez ASP.NET Core, a własne metody rozszerzające, które znów opakowują bibliotekę RawRabbit. Pod spodem wywołany zostaje obiekt BusSubscriber, którego implementacja przedstawia się następująco:

 


internal class BusSubscriber : IBusSubscriber
{
    private readonly IBusClient _busClient;
    private readonly IServiceProvider _serviceProvider;

    public BusSubscriber(IApplicationBuilder app)
    {
        _serviceProvider = app.ApplicationServices.GetService<IServiceProvider>();
        _busClient = _serviceProvider.GetService<IBusClient>();
    }

    public IBusSubscriber SubscribeCommand<TCommand>(string queueName = null) where TCommand : ICommand
    {
        _busClient.SubscribeAsync<TCommand, CorrelationContext>((command, ctx) =>
        {
            var commandHandler = _serviceProvider.GetService<ICommandHandler<TCommand>>();
            return commandHandler.HandleAsync(command, ctx);

        }, ctx => ctx.UseSubscribeConfiguration(cfg => cfg.FromDeclaredQueue(q => q.WithName(GetQueueName<TCommand>(queueName)))));

        return this;
    }

    public IBusSubscriber SubscribeEvent<TEvent>(string queueName = null) where TEvent : IEvent
    {
        _busClient.SubscribeAsync<TEvent, CorrelationContext>((@event, ctx) =>
        {
            var eventHandler = _serviceProvider.GetService<IEventHandler<TEvent>>();
            return eventHandler.HandleAsync(@event, ctx);

        }, ctx => ctx.UseSubscribeConfiguration(cfg => cfg.FromDeclaredQueue(q => q.WithName(GetQueueName<TEvent>(queueName)))));

        return this;
    }

    private static string GetQueueName<T>(string name = null)
        => (string.IsNullOrWhiteSpace(name)
            ? $"{Assembly.GetEntryAssembly().GetName().Name}/{typeof(T).Name.Underscore()}"
            : $"{name}/{typeof(T).Name.Underscore()}").ToLowerInvariant();
}

 

Mam nadzieję, że kod jest prosty do zrozumienia. Wszystko co się tu dzieje to subskrypcja pod konkretny typ wiadomości (binding odbywa się po nazwie klasy, a nie konkretnym assembly dlatego mając dwie kopie tej samej komendy w API i w mikroserwise, nadal to działa). W momencie otrzymania komendy o konkretnym typie, wywołana zostaje metoda GetService, która dostarcza implementację odpowiedniego command handlera. Jak się zapewne domyślasz, każdy command handler implementuje poniższy interfejs:

 


    public interface ICommandHandler<in TCommand> where TCommand : ICommand
    {
        Task HandleAsync(TCommand command, ICorrelationContext context);
    }

 

W momencie gdy implementacja zostaje dostarczona przez kontener, wywołana na niej zostaje metoda HandleAsync, która to zawiera już logikę biznesową. Ot cała magia.

Jeżeli jesteś ciekaw co robi kod w metodzie UseSubscribeConfiguration to sprawie, że tworzony zostaje exchange per typ wiadomości 😉 W tym wpisie nie jest to jednak istotne.

Przejdźmy do implementacji command handlera, który odpowiedzialny jest za obsługę naszej komendy CreateProduct:

 


public sealed class CreateProductHandler : ICommandHandler<CreateProduct>
{
    private readonly IProductsRepository _productsRepository;
    private readonly IHandler _handler;
    private readonly IBusPublisher _busPublisher;

    public CreateProductHandler(
        IProductsRepository productsRepository,
        IHandler handler,
        IBusPublisher busPublisher)
    {
        _productsRepository = productsRepository;
        _handler = handler;
        _busPublisher = busPublisher;
    }

    public async Task HandleAsync(CreateProduct command, ICorrelationContext context)
        => await _handler
            .Handle(async () =>
            {
                var product = new Product(command.Id, command.Name, command.Description, command.Vendor, command.Price);
                await _productsRepository.CreateAsync(product);
            })
            .OnSuccess(async () =>
            {
                await _busPublisher.PublishAsync(new ProductCreated(command.Id), context);
            })
            .OnCustomError(async Exception => 
            {
                await _busPublisher.PublishAsync(new UpdateProductRejected(command.Id, Exception.Message, Exception.Code), context);
            })
            .ExecuteAsync();        
}


 

Zacznę od razu od IHandler, ponieważ znów jest to niewymagany zabieg kosmetyczny, który sprawia, że kod wygląda bardziej funkcyjnie. Jego rolą w największym uproszczeniu jest opakowanie bloku try-catch. Jeżeli żaden wyjątek nie zostanie rzucony w sekcji Handle, to wywołany zostanie blok OnSuccess. W przeciwnym przypadku wykona się blok OnCustomError.

Sama obsługa komendy robi dokładnie to czego można było się spodziewać. Tworzy nowy produkt, a do jego konstruktora przekazuje dane z komendy. Sam obiekt domenowy produktu prezentuje się następująco:

 


public class Product : BaseEntity
{
    public string Name { get; protected set; }
    public string Description { get; protected set; }
    public string Vendor { get; protected set; }
    public decimal Price { get; protected set; }

    public Product(Guid id, string name, string description, string vendor, decimal price)
        :base(id)
    {
        Vendor = vendor;
        SetName(name); 
        SetDescription(description);
        SetPrice(price);
    }

    public void SetName(string name)
    {
        if(string.IsNullOrEmpty(name))
        {
            throw new DShopException("Product name cannot be empty.");
        }

        Name = name;
        SetUpdatedDate();
    }

    public void SetDescription(string description)
    {
        if (string.IsNullOrEmpty(description))
        {
            throw new DShopException("Product description cannot be empty.");
        }

        Description = description;
        SetUpdatedDate();
    }


    public void SetPrice(decimal price)
    {
        if (price <= 0)
        {
            throw new DShopException("Product price cannot be zero or negative.");
        }

        Price = price;
        SetUpdatedDate();
    }
}


 

Jak widzisz nie jest to encja anemiczna, a obiekt który dba o swoją spójność i poprawność danych. Teraz powinieneś już dostrzec zasadność IHandler. Jeżeli przykładowo komenda CreateProduct zawierałaby Price z wartością 0, to w środku obiektu Product rzucony zostanie DShopException, a w handlerze wykona się blok OnCustomError.

Jeżeli wszystkie dane są jednak poprawne to obiekt zostaje utworzony, a następnie zapisany do MongoDB poprzez IProductsRepository. Następnie handler wykonuje blok OnSuccess, w którym publikuje event ProductCreated (zasadność publikacji eventu będzie również poruszona na blogu)Na tym całe flow się kończy 🙂

Podsumujmy skrótowo co dzieje się w mikroserwisie:

  1. Usługa wykorzystuje IBusSubscriber, aby zasubskrybować się pod komendę CreateProduct
  2. Po odebraniu komendy zostaje dostarczona implementacja odpowiedniego command handlera, na którym wywołana zostaje metoda HandleAsync
  3. CreateProductHandler tworzy instancję obiektu Product, przekazując do konstruktora dane z komendy
  4. Obiekt zostaje zapisany do MongoDB poprzez IProductRepository
  5. CreateProductHandler wykorzystuje IBusSubscriber, aby opublikować event ProductCreated

 

To wszystko jeśli chodzi o zapis danych. Domyślam się, że z początku może wydawać się to dosyć skomplikowane, ale z czasem wszystko stanie się klarowne i bardzo przejrzyste 😉 W przyszłym wpisie przedstawię drugą część CQRS czyli odczyt danych. Oczywiście jeżeli posiadasz jakieś pytania do przedstawionego kodu lub ogólnie projektu DShop to zachęcam Cię do komentowania tego wpisu.

 

Na zakończenie jeszcze jedna informacja. Na YouTube dostępna jest już prezentacja „Dsitributed .NET Core”… i to z dwóch eventów 😀 Jeżeli jeszcze jej nie widziałeś to gorąco zapraszam 🙂

 

LINKI DO PREZENTOWANEGO KODU:

 

You may also like...

  • Pingback: dotnetomaniak.pl()

  • Vidur

    Thanks for sharing the excellent details. There is no test code written for this microservice. Is there a reason for it?

  • Hej,

    Mam kilka pytań. Możliwe, że na część z nich planowałeś odpowiedzieć w kolejnych postach 😉

    1. Jak wiemy nazywanie jest jedną z najtrudniejszych rzeczy w informatyce. Co jeśli 2 programistów zdefiniuje, w 2 lub więcej API, komendy tej samej nazwie, ale o zupełnie innym znaczeniu. Czy dobrze rozumiem, że ten scenariusz nie jest obsługiwany w Twoim podejściu?

    2. Dajmy na to, że w naszej aplikacji mamy widok z lista kont bankowych. Użytkownik może dodać nowe konto i to robi. Po wysłaniu requesta dostaje tylko odpowiedź, że żądanie utworzenia konta zostało zaakceptowane. Jak napisałeś komenda może zostać przetworzona dopiero za jakiś czas. Pytanie brzmi, kiedy widok z listą kont się odświeży? Czy użytkownik musi to zrobić samemu? Podobną sytuację możemy mieć przy komendzie aktualizacji czegoś. Komenda zostanie wykonana za jakiś czas ale skąd użytkownik ma o tym wiedzieć.

    3. Co z przypadkiem kiedy 2 lub więcej użytkowników edytuje te same dane. Chcielibyśmy, aby użytkownik został poinformowany o tym, że ktoś w międzyczasie zmienił te same dane co on.

    4. W jaki sposób klient API dowiaduje się, że wykonanie „jego” komendy nie powiodło się?

    • Hej Michał!
      Postaram się odpowiedzieć na Twoje pytania:

      1. Okazało się, że w chwili pisania wpis jednak był już lekko „przedawniony”, ponieważ Piotrek zrobił 2 małe zmiany w projekcie DShop.Common. Jedna z nich to fix własnie tego problemu 😀 Tu kod: https://github.com/devmentors/DNC-DShop.Common/blob/master/src/DShop.Common/RabbitMq/BusSubscriber.cs

      2. Do śledzenie statusu operacji służy pominięty ICorrelationContext. Zawiera on metadane przekazywane oprócz wiadomości w RabbitMQ. Mówiąc najprościej… to jak numer referencyjny paczki nadanej kurierem. Dzięki niej śledzimy całe flow, a kiedy się ono zakończy (niekoniecznie sukcesem) robimy push używając WS z SignalR. Po wysłaniu komendy do API, numer operacji jest w nagłówku odpowiedzi HTTP 202. Klient ją odbiera i może nasłuchiwać na aktualizacje stanu. Tyle w skrócie, reszta wkrótce na blogu 😉

      3. Jeżeli jest to zjawisko powszechne to można pomyśleć o wprowadzeniu wersjonowania i optimistic concurency. W większości przypadków (z jakimi ja się spotkałem) jest to raczej przypadek dość specyficzny i wtedy podzielam zdanie Grega Younga. Nie warto poświęcać mnóstwa czasu (co za tym idzie pieniędzy) na obsługę przypadku, która może zajść raz w 0.01%. Lepiej po prostu raz na rok przyjąć mailowe zgłoszenie, że coś nie działa i to naprawić. Przykro mi, ale lepszej odpowiedzi nie mam.

      4. Patrz punkt 2. Po odebraniu operationId, klient nasłuchuje poprzez WS na kolejne statusy operacji. Jednym z nich może być informacja, że operacja się nie powiodła z powodu XYZ,

    • Dlatego kluczem jest rozpoczynanie projektu od stworzenia dziedzinowego słownika pojęć (namespace) i konserwowanie go dla aplikacji.