Mediator Ostatnio zrobiłem webinar na temat budowania aplikacji w C# od zera. Skoncentrowałem się na dobrych praktykach oraz na najlepszych paczkach NuGet, które obecnie są w ofercie dla C# i dla .NET. 

MediatR na pewno jest w pierwszej piątce najlepszy paczek Nuget.

MediatR pomaga Ci szybko napisać aplikację przy pomocy wzorca projektowego mediator oraz zastosować CQRS. 

mediator jaki problem rozwiązuje

O co chodzi? Chodzi o to, że masz jeden obiekt, którego zadaniem jest określenie komunikacji między obiektami. W ten sposób unikasz faktu, że obiekty muszą gadać bezpośrednio do siebie albo muszą korzystać ze swoich interfejsów.

mediator w akcji

Wyobraź sobie sekretarkę, która wie, jakie dokumenty mają iść do jakiej osoby w biurowcu. Chcesz więc wysłać swoje dokumenty więc idziesz do sekretarki.

Bezpośrednio ty nie wiesz do kogo pójdą twoje papiery. Ważne, że to sekretarka wie. Sekretarka jest takim mediatorem.

Dzięki temu twoje klasy nie będą między sobą powiązane. Unikniemy w ten sposób referencji jednej klasy do drugiej.

Zalety :

  • Zmiany potem łatwo dokonać
  • Kod taki łatwo się testuje
  • Cały twój kod ma potem pewien wzór, który potem może być użyty ponownie w kodzie przez innego programistę bez problemu
  • Łatwo znaleźć odpowiednie funkcje w kodzie
  • Zasada "Single Responsibility Principle" tutaj jest traktowana bardzo poważnie. Nie można już tej zasady spełnić lepiej.

MediatR potrafi także na dwa sposoby przetworzyć komunikaty

  • Request : Wysyła komunikaty do jednego obiektu/serwisu. Może (ale nie musi) zwrócić rezultat do bezpośredniej klasy, która wywoła tego mediatora.
  • Notfications : Wysyła komunikaty do wielu obiektów/serwisów. Niestety wtedy nic nie może zostać zwrócone.

Dla stylu wysłania Request mamy następujące interfejsy.

Oto dwa interfejsy do wysłanie wiadomości do naszej sekretarki

  • IRequest : Określa komunikat, który będzie wysłać do naszej sekretarki
  • IRequestHandler : Określa co ma zostać zwrócone dla określonego komunikatu.

Zobaczmy jak działa to w praktyce w ASP.NET CORE 5.O. 

ASP.NET CORE 5.O.

Instalujemy odpowiednie paczki NuGet i jedziemy

MediatR paczka NuGet

W klasie Startup musimy zarejestrować użycie MediatR. W końcu ta paczka NuGet musi potem wykryć wszystkie obiekty, które będą implementować odpowiednie interfejsy.

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
  // other services

  services.AddMediatR(typeof(Startup));
}

Jeśli korzystasz z innego kontenera wstrzykiwania zależności to nie ma problemu. Wiki nadchodzi Ci z pomocą : https://github.com/jbogard/MediatR/wiki

Do zabawy przydadzą się jakieś klasy statyczne z gotowymi listami obiektów. Patrz oto klasy reprezentujące wpisy i kategorię na bloga.

public static class DummyPosts
{
    public static List<Post> Get()
    {
        var cat = DummyCategories.Get();

        Post p1 = new Post()
        {
            Author = "Damian",
            Date = DateTime.Now.AddMonths(-6),
            Description = @"Nasze aplikacje ASP.NET CORE coraz częściej są tylko aplikacją REST. To oczywiście wymaga Walidacji po stronie klienta i po stronie serwera
Jak taką walidację jak najszybciej zrobić.Może przecież sam napisać takie warunki,
            ale przy dużej ilości klas,
            które występują jako parametry mija się to z celem.

Możesz też skorzystać z atrybutów i oznaczyć reguły do każdej właściwości.",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 1,
            Rate = 8,
            CategoryId = DummySeed.Aspnet,
            Title = "Walidacja z FluentValidation w ASP.NET Core + Swagger",
            Url = "https://cezarywalenciuk.pl/blog/programing/walidacja-z-fluentvalidation-waspnet-core--swagger"
        };

        Post p2 = new Post()
        {
            Author = "Damian",
            Date = DateTime.Now.AddMonths(-6),
            Description = @"Programiści codziennie tworzą jakąś aplikację sieciową typu REST. Teraz nastaje pytanie, jak najlepiej zrozumieć jak dane API działa. Do tego mamy dokumentacje, ale jeśli pracujesz w szybkich, zamkniętych projektach to takiej dokumentacji może nie być.",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 2,

            CategoryId = DummySeed.Aspnet,
            Rate = 7,
            Title = "Swagger UI : Dokumentowanie API w ASP.NET CORE",
            Url = "https://cezarywalenciuk.pl/blog/programing/swagger-ui--dokumentowanie-api-w-aspnet-core"
        };

        Post p3 = new Post()
        {
            Author = "Stefan",
            Date = DateTime.Now.AddMonths(-12),
            Description = @"W pod koniec roku 2017 zacząłem ćwiczyć. Proste ćwiczenia rzeczywiście robią różnice, gdy masz siedzący tryb życia. A co z bieganiem ?
Pamiętam jak pierwszy raz na bieżni nie byłem w stanie wytrzymać 5 minut normalnego spaceru. Powoli z tygodnia na dzień zacząłem sobie stawiać wyższe progi i tak odkryłem, że o ile jest to na początku bolesne to jak twoje ciało da Ci te endorfiny to już...aż chce się biegać więcej. ",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 3,
            CategoryId = DummySeed.Filzofia,
            Rate = 5,
            Title = "Bieganie jak się do tego zmotywować : Zdrowie Programisty",
            Url = "https://cezarywalenciuk.pl/blog/programing/bieganie-jak-sie-do-tego-zmotywowac--zdrowie-programisty"
        };

        Post p4 = new Post()
        {
            Author = "Damian",
            Date = DateTime.Now.AddMonths(-12),
            Description = @"Logowanie działania aplikacji. Jak wiedzieć w końcu, gdy coś nie działa. Mój blog jest napisany w C# i działa po ASP.NET CORE. Jak to jednak bywa z napisaną przez siebie aplikacją pojawiają się błędy więc do bloga dodałem mechanizm logowania błędów. W taki sposób znalazłem wiele dziwnych przypadków uszkodzonych wpisów w formacie XML, które rozwalały Parser. Znalazłem też złe zbudowane przez ze mnie lista kursów. ",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 4,
            CategoryId = DummySeed.Aspnet,
            Rate = 5,
            Title = "NLog z ASP.NET Core : Logowanie błędów w aplikacji",
            Url = "https://cezarywalenciuk.pl/blog/programing/nlog-z-aspnet-core--logowanie-b%C5%82edow-w-aplikacji"
        };

        Post p5 = new Post()
        {
            Author = "Damian",
            Date = DateTime.Now.AddMonths(-12),
            Description = @"Logowanie działania aplikacji. Jak wiedzieć w końcu, gdy coś nie działa. Mój blog jest napisany w C# i działa po ASP.NET CORE. Jak to jednak bywa z napisaną przez siebie aplikacją pojawiają się błędy więc do bloga dodałem mechanizm logowania błędów. W taki sposób znalazłem wiele dziwnych przypadków uszkodzonych wpisów w formacie XML, które rozwalały Parser. Znalazłem też złe zbudowane przez ze mnie lista kursów. ",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 5,
            CategoryId = DummySeed.Aspnet,
            Rate = 5,
            Title = "NLog z ASP.NET Core : Logowanie błędów w aplikacji",
            Url = "https://cezarywalenciuk.pl/blog/programing/nlog-z-aspnet-core--logowanie-b%C5%82edow-w-aplikacji"
        };

        Post p6 = new Post()
        {
            Author = "Damian",
            Date = DateTime.Now.AddMonths(-8),
            Description = @"W tym  artykule zobaczymy jak zintegrować AutoMapper  z ASP.NET CORE dla .NET 5, chociaż bądźmy szczerzy możesz skorzystać z tej biblioteki w każdym projekcie w C#.",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 6,
            CategoryId = DummySeed.Aspnet,
            Rate = 9,
            Title = "AutoMapper z ASP.NET CORE czyli mapowanie klas",
            Url = "https://cezarywalenciuk.pl/blog/programing/automapper-z-aspnet-core"
        };

        Post p7 = new Post()
        {
            Author = "Adrian",
            Date = DateTime.Now.AddMonths(-14),
            Description = @"Nagrywanie Gif - ów ? Robienie obrazków na bloga ? Jak to robić jeszcze szybciej ? ",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 7,
            CategoryId = DummySeed.TrikiZWindows,
            Rate = 4,
            Title = "ShareX : Lepszy PrintScreen oraz robienie Gif-ów twojego pulpitu?",
            Url = "https://cezarywalenciuk.pl/blog/programing/sharex-lepszy-printscreen-oraz-robienie-gif-ow"
        };

        Post p8 = new Post()
        {
            Author = "Adrian",
            Date = DateTime.Now.AddMonths(-15),
            Description = @"Jak jeszcze lepiej ulepszyć system operacyjny Windows.

Czy być może programy tobie, które za chwilę to śmieci, które nie będą ci potrzebne?

Zazwyczaj w tym cyklu pokazuje programy, z które moim bardzo zmieniają przepływ mojej pracy.",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 8,
            CategoryId = DummySeed.TrikiZWindows,
            Rate = 5,
            Title = "QuickLook, TeraCopy, ProcessExplorer czy to potrzebne jest ?",
            Url = "https://cezarywalenciuk.pl/blog/programing/sharex-lepszy-printscreen-oraz-robienie-gif-ow"
        };

        Post p9 = new Post()
        {
            Author = "Adrian",
            Date = DateTime.Now.AddMonths(-15),
            Description = @"Jak jeszcze lepiej ulepszyć system operacyjny Windows.

Czy być może programy tobie, które za chwilę to śmieci, które nie będą ci potrzebne?

Zazwyczaj w tym cyklu pokazuje programy, z które moim bardzo zmieniają przepływ mojej pracy.",
            ImageUrl = "https://cezarywalenciuk.pl/Posts/programing/icons/_withbackground/R2/656_walidacja-z-fluentvalidation-waspnet-core--swagger.png",
            PostId = 9,
            CategoryId = DummySeed.Docker,
            Rate = 9,
            Title = "Docker File dla Go, ASP.NET Core, .NET 5, Java Spring, NodeJS, Python",
            Url = "https://cezarywalenciuk.pl/blog/programing/docker-file-dla-go-aspnet-core-net-5-java-spring-nodejs-python"
        };

        List<Post> p = new List<Post>();
        p.Add(p1); p.Add(p3);
        p.Add(p2); p.Add(p4);
        p.Add(p5); p.Add(p6);
        p.Add(p8); p.Add(p7);
        p.Add(p9);

        return p;
    }
}

public static class DummyCategories
{
    public static List<Category> Get()
    {
        Category c1 = new Category()
        {
            CategoryId = DummySeed.Csharp,
            Name = "CSharp",
            DisplayName = "C#"
        };

        Category c2 = new Category()
        {
            CategoryId = DummySeed.Aspnet,
            Name = "aspnet",
            DisplayName = "ASP.NET"
        };

        Category c3 = new Category()
        {
            CategoryId = DummySeed.TrikiZWindows,
            Name = "triki-z-windows",
            DisplayName = "Triki z Windows"
        };

        Category c4 = new Category()
        {
            CategoryId = DummySeed.Docker,
            Name = "docker",
            DisplayName = "Docker"
        };

        Category c5 = new Category()
        {
            CategoryId = DummySeed.Filzofia,
            Name = "filozofia",
            DisplayName = "Filozofia"
        };


        List<Category> p = new List<Category>();
        p.Add(c1); p.Add(c3);
        p.Add(c2); p.Add(c4);
        p.Add(c5);

        return p;

    }
}

public static class DummySeed
{
    public static int Csharp = 1;
    public static int Aspnet = 2;
    public static int TrikiZWindows = 3;
    public static int Docker = 4;
    public static int Filzofia = 5;
}

public class Post
{
    public int PostId { get; set; }

    //[Required]
    //[StringLenght(80)]
    public string Title { get; set; }

    //[StringLenght(40)]
    public string Author { get; set; }

    public DateTime Date { get; set; }
    public string Description { get; set; }

    public int CategoryId { get; set; }
    public Category Category { get; set; }

    public string ImageUrl { get; set; }
    public string Url { get; set; }

    //[Range(0, 100)]
    public int Rate { get; set; }
}

public class Category
{
    public int CategoryId { get; set; }

    public string Name { get; set; }

    public string DisplayName { get; set; }

    public ICollection<Post> Posts { get; set; }
}

Teraz w naszym kontrolerze chciałbym pobrać wszystkie wpisy.

public class PostController : Controller
{
    [HttpGet(Name = "GetAllPosts")]
    public async Task<ActionResult<List<Post>>> GetAllPosts()
    {

    }
}

Wysłanie Request-ów

Nasze zapytanie do mediatora powinno implementować interfejs IRequest albo IRequest<TResponse>. Wszystko zależy od tego, czy chcesz zwracać dane, czy nie. 

Gdybyśmy chcieli dodać jakieś parametry do tego zapytania to zrobiliśmy by to tutaj. 

public class GetAllPostsQuery 
    : IRequest<List<Post>>
{
}

Dodajmy więc typ wyliczeniowy, aby to pokazać.

public enum OrderByPostOptions
{
    None = 0,
    ByTitle = 1,
    ByDate = 2,
    ByAuthor = 3
}

Będzie sortować naszą zwracaną listę wpisów.

public class GetAllPostsQuery
    : IRequest<List<Post>>
{
    public OrderByPostOptions OrderBy { get; set; }
}

Teraz musimy stworzyć klasę, która obsłuży to zapytanie. Musi ona implementować IRequestHandler w następujący sposób.

public class GetAllPostsQueryHandler : IRequestHandler
    <GetAllPostsQuery, List<Post>>
{
    public Task<List<Post>> Handle(GetAllPostsQuery request, 
        CancellationToken cancellationToken)
    {
        var posts = DummyPosts.Get();
        if (request.OrderBy == OrderByPostOptions.ByAuthor)
            return Task.FromResult
                (posts.OrderBy(p => p.Author).ToList());
        else if (request.OrderBy == OrderByPostOptions.ByDate)
            return Task.FromResult
                (posts.OrderBy(p => p.Date).ToList());
        else if (request.OrderBy == OrderByPostOptions.ByTitle)
            return Task.FromResult
                (posts.OrderBy(p => p.Title).ToList()); 
        
        return Task.FromResult
               (posts);
    }
}

Teraz chcemy wywołać nasze zapytanie w naszym mediatorze. Oto jak to robimy.

public class PostController : Controller
{
    private readonly IMediator _mediator;
    public PostController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet(Name = "GetAllPosts")]
    public async Task<ActionResult<List<Post>>> GetAllPosts()
    {
        var request = new GetAllPostsQuery 
        {
            OrderBy = OrderByPostOptions.ByDate
        };
        var result = await _mediator.Send(request);

        return result;
    }
}

Jak widzisz musimy strzyknąć IMediator do naszego kontrolera. Sam kontroler nie wie kto wykona jego zapytanie. Ważne otrzyma listę wpisów, o którą poprosił.

Warto zaznaczyć, że gdybyś miał polecenie, które nic nie zwraca to wtedy musisz skorzystać z specjalnej klasy UNIT, która reprezentuje dla MediatoR NIC.

Oto przykład takiego przypadku gdy chcemy aktualizować wpis i otrzymać NIC po wykonaniu takiego polecenia.

public class PostController : Controller
{
    [HttpPut(Name = "UpdatePost")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    [ProducesDefaultResponseType]
    public async Task<ActionResult> Update([FromBody]
        UpdatePostCommand updatePostCommand)
    {
        await _mediator.Send(updatePostCommand);

        return NoContent();
    }

Oto polecenie aktualizacji wpisu.

public class UpdatePostCommand : IRequest
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Author { get; set; }

    public DateTime Date { get; set; }
    public string Description { get; set; }

    public int CategoryId { get; set; }
    public Category Category { get; set; }

    public string ImageUrl { get; set; }
    public string Url { get; set; }
    public int Rate { get; set; }
}

W klasie obsługujące te polecenie będziemy zwracać właśnie specjalną wartość UNIT, która określa, że nic nie zwracamy.

public class UpdatePostCommandHandler : IRequestHandler
    <UpdatePostCommand, Unit>
{
    public Task<Unit> Handle(UpdatePostCommand request,
        CancellationToken cancellationToken)
    {
        Post p = new Post()
        {
            Author = request.Author,
            Category = request.Category,
            CategoryId = request.CategoryId,
            Date = request.Date,
            Description = request.Description,
            ImageUrl = request.ImageUrl,
            PostId = request.PostId,
            Rate = request.Rate,
            Title = request.Title,
            Url = request.Url
        };

        //UPDATED

        return  Task.FromResult(Unit.Value);
    }
}

CQRS ?

Jak widzisz zrobiłem mały podział do wysłania komunikatów pomiędzy poleceniami a zapytaniami.

CQRS jak to wygląda w projekcie

Skrót CQRS rozwijamy do "Command Query Responsibility Segregation".

Jak widzisz zrobiłem szybką segregację komunikatów na Polecenia i zapytania.

Filozofia jest prosta.

Query to zapytanie, które zwraca jakąś informację, ale zmienia systemu np. pobranie wszystkich wpisów.

Natomiast Command, czyli Polecenie to wszystko co ma wpływ na zmianę systemu np. aktualizacja wpisu.

Jak widzisz nawet bez twojej wiedzy w bardzo łatwy sposób wyjaśniłem Ci jak możesz wprowadzić CQRS do swojej aplikacji bez dłuższego gadania filozoficznego o co chodzi z tym wzorcem projektowym.

Oto przykład tego, jak na moim webinarze ostatecznie projekt EduZbieracz wyglądał, gdy skończyłem implementacje CQRS.

CQRS w EduZbieraczu mnóstwo klas

Wadą całego tego wzorca CQRS oraz Mediator jest fakt, że będziesz mnóstwo klas. Natomiast przynajmniej możesz spać spokojnie, bo zasada "Single Responsibility Principle" nie może już być realizowana lepiej.

Zalety CQRS:

  • Skalowanie
  • Bezpieczeństwo
  • Łatwe do zmiany
  • Uczy schematu działania i szukania w kodzie    

Wady CQRS :

  • Zwiększa liczbę klas 😱
  • Zwiększona złożoność aplikacji 🥴
  • Dobry pomysł dla dużych aplikacji 🤠  . Dla małych nie

Slajd CQRS z prelekcji Aplikacja C# od Zera : Architektura, CQRS, Dobre praktyki

Wysłanie Notyfikacji

MediatR daje też możliwość wysłania Notyfikacji. Wygląda to podobnie do wysłania requestów.

Różnica polega na tym, że stworzyć wiele obiektów, które będą obsługiwać tą notyfikacje.

Nie wiem jak traktować Notyfikacje do wzorca CQRS wiem jedno skoro Notyfikacje nie mogą niczego zwracać to możemy to traktować jako Command, czyli polecenie pod warunkiem, że coś zmieniamy w naszym systemie.

Wysłanie Notyfikacji projekt

Chociaż zapewne dla bezpieczeństwa lepiej by byłoby nie mieszać tych Notyfikacji do wzorca CQRS. 

public class WritePostNotification
    : INotification
{
    public string WhatToWrite { get; set; }
}

Zapiszemy w konsoli wynik naszej notyfikacji.

public class WritePostNotificationConsoleHandler :
    INotificationHandler<WritePostNotification>
{
    public Task Handle(WritePostNotification notification,
        CancellationToken cancellationToken)
    {
        Console.WriteLine(notification.WhatToWrite);

        return Task.CompletedTask;
    }
}

Odpalimy także tą klasę.

public class WritePostNotificationDebuggerHandler :
    INotificationHandler<WritePostNotification>
{
    public Task Handle(WritePostNotification notification, 
        CancellationToken cancellationToken)
    {
        Debugger.Log(1,"Info",
            notification.WhatToWrite);

        return Task.CompletedTask;
    }
}

Kontroler korzysta z metody "Publish", aby wysłać notyfikacje.

public class PostController : Controller
{
    [HttpGet(Name = "WritePost")]
    public async Task<ActionResult> WritePost(string message)
    {
        await _mediator.
            Publish(
            new WritePostNotification()
            { 
                WhatToWrite = message
            });

        return NoContent();
    }

Pipeline Behaviours

Pipeline Behaviours to typ middleware, czyli pośrednika, który uruchamia się przed i po twoim zapytaniu czy poleceniu, czy notyfikacji.

Mogą one się przydać w logowaniu i przechwytywania błędów oraz walidacji.

Pośredników możesz łączyć w łańcuch wywołań. Oto dwa przykład Logowania z  Pipeline Behaviours:

public class ConsoleWriteLineBehavior<TRequest, TResponse> :
IPipelineBehavior<TRequest, TResponse>
{

    public ConsoleWriteLineBehavior()
    {

    }

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        Console.WriteLine($"Handling {typeof(TRequest).Name}");

        // go to the next behaviour in the chain/to the request handler
        var response = await next();

        Console.WriteLine($"Handled {typeof(TResponse).Name}");

        return response;
    }
}

Logowanie powinno być bardziej porządne niż jakieś Console.WriteLine

public class LoggingBehavior<TRequest, TResponse> : 
    IPipelineBehavior<TRequest, TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        _logger.LogInformation($"Handling {typeof(TRequest).Name}");

        var response = await next();

        _logger.LogInformation($"Handled {typeof(TResponse).Name}");

        return response;
    }
}

Na koniec pozostało ci zarejestrować te zachowania.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMediatR(typeof(Startup));

    services.AddTransient(typeof(IPipelineBehavior<,>),
        typeof(LoggingBehavior<,>));

    services.AddTransient(typeof(IPipelineBehavior<,>),
        typeof(ConsoleWriteLineBehavior<,>));

Podsumowanie

Jak widzisz dzięki paczce MediatR jak łatwo jest użyć wzorca Mediator z CQRS.

Zobaczyłeś jak możesz wywołać polecenia i zapytania w stylu jeden do jednego.

Zobaczyłeś także jak możesz wysłać notyfikację w stylu jeden do wielu.

Masz także możliwość pisania swoich pośredników (Pipeline Behaviours) i pisania logiki do swoich komunikatów, gdy logika w nich się powtarza. W sumie to programowanie Aspektowe :)

Miłego programowania i polecam obejrzeć swój webinar, na którym krok po kroku napisałem bardzo rozbudowaną aplikację używając CQRS.

Kod do pobrania tutaj : https://github.com/PanNiebieski/example-MediatR-ASPNETCORE5-CQRS