EventDriven CQRS to jeden z najczęszczych wzorców projektów przy budowaniu aplikacji ASP.NET CORE. Fundament polega na oddzieleniu kodu, który odczytuje dane (Query) i na kod który te dane modyfikuje (Command).

Jeśli znasz operacje Create-Read-Update-Delete, to pomyśl o Query jako zapytaniach Read, a Command jako modyfikację, które Tworzą-Aktualizują bądź kasują dane.

Biblioteka MediatR, którą opisałem wcześniej, wspaniale przyspiesza pisania aplikacji CQRS z małym dodatkiem wzorca projektowego Mediator. Oczywiście stawiając pierwsze kroki z aplikacją CQRS, można popełnić błędy. Pamiętam jak ktoś, na moim pierwszym webinarze na temat CQRS zapytał wprost, czy Command może uruchomić kolejny Command.

Oczywiście jest to kiepski pomysł

Oto proste polecenie Command napisane przy pomocy MediatR.

public class CreateDocumentCommandHandler : IRequestHandler<CreateOrderCommand, Unit>
{
    private readonly IMediator _mediator;
    private readonly DocumentContext _context;
public CreateDocumentCommandHandler (IMediator mediator, DocumentContext context)
{ _mediator = mediator; _context = context; } public async Task<Unit> Handle( CreateDocumentCommand request, CancellationToken cancellationToken)
{ var document= request.MapToDocument(); _context.Document.Add(document);
await _context.SaveChangesAsync(); return Unit.Value; } }

Wygląda to prosto. Jak widzisz, to polecenie tworzy nowy dokument w bazie danych i na podstawie kodu możesz stwierdzić, że jest to Entity Framework

Teraz powiedzmy, że po utworzeniu dokumentu chciałbyś wysłać notyfikację do zespołu marketingowego oraz chciałbyś wysłać e-mail o tym dokumencie do zespołu księgowego.

Przeróbmy metodę Handle, aby zobaczyć, jak byś sobie z tym poradzili

public async Task<Unit> Handle(
        CreateDocumentCommand request, CancellationToken cancellationToken)
{
    var document = request.MapToDocument();

    _context.Document.Add(document);
    await _context.SaveChangesAsync();

    await _mediator.Send(new SendMarketingNotificationCommand(document.UId));
await _mediator.Send(new SendEmailDocumentToAccountingDepartmentCommand(document));
return Unit.Value; }

Na razie wszystko działa.

Teraz parę tygodni później dowiadujesz się, że Dokument powinien być tworzony na jeszcze inne sposoby np.

  • Zamówienie produktu automatycznie powinno być transformowane na dokument
  • Tworzenie dokumentu na bazie innego dokumentu z poprawkami 

Tę akcję oczywiście utworzą nam kolejne polecenia Command. ProductToDocumentCommandHandler oraz FixedNewDocumentCommandHandler.

Co jednak z efektami ubocznymi przy tworzeniu samego dokumentu? Co z wysłaniem notyfikacji do działu marketingowego i co wysyłki e-mail do działu księgowego.

Teraz te dwie linijki kodu muszą zostać powtórzone do każdego polecenia. Komplikuje to też śledzenie swojego procesu biznesowego, ponieważ tak naprawdę nasze 1 polecenie CreateDocument robi więcej niż 1 rzecz.

Jak to naprawić ? Po pierwsze nie warto mieszać Poleceń.  Po drugie trzeba unikać tworzenia poleceń, które robią więcej niż 1 rzecz. 

Z drugiej strony chcemy, aby po utworzeniu dokumenty te notyfikacje były nadal. Można by było wysłać ten problem wyżej w przypadku ASP.NET Core były to Controller.

Jednak problem z modelowaniem by istniał, bo byś miał  problem z cyklu "jakie metody REST API wykonują jaki zbiór poleceń na raz". Wtedy byś się zastanawiał czy metody REST API powinny robić także jedną rzecz, czy mogą pozwolić sobie na wywoływanie wielu poleceń.

Problem z Command wywołujący kolejny Command

Zdarzenia jako INotificationHandler na pomoc

Jednak najlepszy rozwiązaniem na ten problem są zdarzenia. MediatR nawet może nam w tym pomóc i potraktować to zdarzenie jak notyfikację, którą on potrafi obsłużyć. Stąd interfejs INotification

Przy tworzeniu dokumentu nasz Command wysłałoby zdarzenie DocumentCreatedEvent.

public class DocumentCreatedEvent: INotification
{
    public DocumentCreatedEvent(Document d)
    {
        Doc = d;
    }

    public Document Doc { get; }
}

Z punktu widzenia mapowania procesu biznesowego ma to sens. Utworzenie dokumentu to nie zbiór różnych poleceń.

Utworzenie dokumentu to polecenie, które wysyła po ukończeniu zdarzenie DocumentCreatedEvent, a ten wywołuje kolejne polecenia jak:  SendMarketingNotificationCommandSendEmailDocumentToAccountingDepartmentCommand

Jak widzisz, zdarzenia mogą wywołać wiele innym reakcji.

public async Task<Unit> Handle(
        CreateDocumentCommand request, CancellationToken cancellationToken)
{
    var document = request.MapToDocument();

    _context.Document.Add(document);
    await _context.SaveChangesAsync();

    await _mediator.Publish(new DocumentCreatedEvent(document));

    return Unit.Value;
}

Pozostaje na napisać łapacza takich zdarzeń w MediatR, który będzie dziedziczyć po INotificationHandler 

public class DocumentCreatedEventHandler: INotificationHandler<DocumentCreatedEvent>
{
    public Task Handle(DocumentCreatedEvent documentEvent, CancellationToken cancellationToken)
    {
            await _mediator.Send(new SendMarketingNotificationCommand(documentEvent.Doc.UId));
            await _mediator.Send(new SendOrderEmailCommand(documentEvent.Doc));
    }
}

W zależności od aplikacji zapewne chciałbyś ustalić kolejność wykonywania takich Handlerów. Możesz mieć ich wiele tylko teraz pytanie, czy mają one wykonywać się asynchronicznie od siebie, czy synchronicznie i zatrzymywać się przy wyjątku.

Te wszystkie opcje masz określone w typie wyliczeniowym PublishStrategy

public enum PublishStrategy
{
    /// <summary>
    /// Run each notification handler after one another. Returns when all handlers are finished. In case of any exception(s), they will be captured in an AggregateException.
    /// </summary>
    SyncContinueOnException = 0,

    /// <summary>
    /// Run each notification handler after one another. Returns when all handlers are finished or an exception has been thrown. In case of an exception, any handlers after that will not be run.
    /// </summary>
    SyncStopOnException = 1,

    /// <summary>
    /// Run all notification handlers asynchronously. Returns when all handlers are finished. In case of any exception(s), they will be captured in an AggregateException.
    /// </summary>
    Async = 2,

    /// <summary>
    /// Run each notification handler on it's own thread using Task.Run(). Returns immediately and does not wait for any handlers to finish. Note that you cannot capture any exceptions, even if you await the call to Publish.
    /// </summary>
    ParallelNoWait = 3,

    /// <summary>
    /// Run each notification handler on it's own thread using Task.Run(). Returns when all threads (handlers) are finished. In case of any exception(s), they are captured in an AggregateException by Task.WhenAll.
    /// </summary>
    ParallelWhenAll = 4,

    /// <summary>
    /// Run each notification handler on it's own thread using Task.Run(). Returns when any thread (handler) is finished. Note that you cannot capture any exceptions (See msdn documentation of Task.WhenAny)
    /// </summary>
    ParallelWhenAny = 5,
}

Tak rozwiązałeś problem związany z modelowaniem biznesowym i powtarzaniem kodu. Możesz też być dumny, bo nie wiele Ci brakuje, aby zrozumieć, jak mapować procesy biznesowe przy pomocy EventStorming.

EventStorming CQRS I EventDriven Application

Dlatego staraj się unikać poleceń, które wywołują kolejne polecenia. Jak widzisz zdarzenia, są najlepszą formą na rozwiązanie tego problemu. Możesz się bawić w metody REST API, które wywołują wiele poleceń, ale wtedy modelowanie procesu biznesowego nie będzie już tak przejrzyste.

Jedno jest pewne. Command wywołujący kolejny Command jest zawsze najgorszą opcją.