Mediator w C# bez chaosu. Handlery, pipeline i dobre praktyki

Przemysław Kwiatkowski 9 czerwca 2026
Schemat przepływu zapisu w mikroserwisie: ASP.NET Core Controller wysyła komendę do Mediator, który przekazuje ją do Command Handler, a następnie do Aggregates i Repository, zapisując dane w bazie.

Spis treści

Gdy kontroler ASP.NET Core zaczyna znać repozytorium, walidator, logger, system płatności i kilka kolejnych usług, problemem nie jest już liczba linii, lecz rosnące powiązanie odpowiedzialności. Wzorzec Mediator w C# pozwala skierować żądanie do właściwego handlera, ograniczyć zależności między klasami i umieścić logowanie, walidację czy autoryzację we wspólnym potoku.

Najważniejsze informacje o wzorcu Mediator w C#

  • Cel: oddzielenie obiektów, które wysyłają żądania, od tych, które je obsługują.
  • Najczęstsze zastosowanie: komendy, zapytania i powiadomienia w aplikacjach ASP.NET Core.
  • Główna korzyść: mniejsze kontrolery oraz centralny pipeline dla walidacji, logowania i transakcji.
  • Największe ryzyko: dodanie warstwy pośredniej tam, gdzie zwykłe wywołanie serwisu byłoby prostsze.
  • Narzędzia: można napisać własną implementację albo użyć biblioteki takiej jak MediatR.

Czym jest Mediator i jaki problem rozwiązuje

W klasycznym podejściu obiekt A wywołuje bezpośrednio obiekt B. Gdy liczba współpracujących klas rośnie, pojawia się sieć zależności trudna do testowania i zmiany. Mediator przejmuje komunikację, dzięki czemu nadawca zna tylko kontrakt wiadomości oraz interfejs pośrednika, a nie konkretną klasę wykonującą operację.

Dobrym przykładem jest endpoint tworzący zamówienie. Kontroler nie musi sam pobierać klienta, sprawdzać dostępności produktu, zapisywać danych i publikować zdarzenia. Wysyła komendę CreateOrderCommand, a dedykowany handler zajmuje się jednym przypadkiem użycia.

W praktyce spotykam dwa znaczenia tego terminu. Pierwsze to klasyczny wzorzec projektowy opisany przez Gang of Four, w którym mediator koordynuje komunikację kilku obiektów. Drugie to popularny styl aplikacyjny oparty na komunikatach i handlerach, często łączony z CQRS. Oba podejścia mają wspólny cel, czyli ograniczenie bezpośrednich zależności, ale nie są dokładnie tym samym.

Uczestnicy wzorca

  • Request lub command opisuje żądanie wykonania operacji.
  • Handler zawiera logikę obsługi konkretnego komunikatu.
  • Mediator odnajduje właściwy handler i przekazuje mu wiadomość.
  • Notification informuje wielu odbiorców o zdarzeniu, bez oczekiwania jednej odpowiedzi.

Ważne jest to, czego mediator nie robi. Nie powinien stawać się miejscem całej logiki biznesowej ani uniwersalnym service locatorem. Jeśli każda operacja przechodzi przez jedną ogromną klasę z dziesiątkami warunków, wzorzec został użyty wbrew swojemu celowi.

Diagram przepływu żądania przez c# mediator, walidację, logowanie i transakcje do handlera.

Jak działa Mediator w C# na konkretnym przykładzie

Najprostszy przepływ wygląda tak: kontroler tworzy komunikat, mediator przekazuje go do handlera, a handler zwraca wynik. Poniżej znajduje się minimalistyczny przykład bez zewnętrznej biblioteki, który dobrze pokazuje samą ideę.

public sealed record GetProductQuery(int Id);

public interface IQueryHandler
{
    Task Handle(TQuery query, CancellationToken cancellationToken);
}

public sealed class GetProductHandler
    : IQueryHandler
{
    private readonly IProductRepository repository;

    public GetProductHandler(IProductRepository repository)
    {
        this.repository = repository;
    }

    public async Task Handle(
        GetProductQuery query,
        CancellationToken cancellationToken)
    {
        var product = await repository.GetById(
            query.Id,
            cancellationToken);

        return product is null
            ? null
            : new ProductDto(product.Id, product.Name);
    }
}

W tym wariancie handler ma jedną odpowiedzialność i łatwo go przetestować. Nadal potrzebujemy jednak mechanizmu, który powiąże typ zapytania z właściwą klasą. Przy kilku handlerach można zrobić to ręcznie, ale w większej aplikacji przydaje się rejestracja przez kontener DI albo gotowa biblioteka.

Komenda, zapytanie i powiadomienie

Najczęściej spotykam trzy rodzaje komunikatów. Komenda zmienia stan systemu, na przykład tworzy fakturę. Zapytanie odczytuje dane i zwykle zwraca wynik. Powiadomienie opisuje fakt, który może obsłużyć wielu odbiorców, na przykład wysłanie wiadomości po opłaceniu zamówienia.

Typ komunikatu Liczba handlerów Przykład Odpowiedź
Komenda Jeden CreateInvoiceCommand Id faktury lub brak wyniku
Zapytanie Jeden GetOrderQuery DTO, lista lub wartość
Powiadomienie Zero, jeden lub wiele OrderPaidNotification Zwykle brak

To rozróżnienie nie jest obowiązkowe, ale porządkuje kod. Sam stosuję je wtedy, gdy zespół chce wyraźnie oddzielić odczyty od operacji zmieniających stan. Nie wciskałbym jednak CQRS do małego CRUD-a tylko po to, aby każda metoda miała osobną klasę.

MediatR, własny mediator czy rozwiązanie generujące kod

W ekosystemie .NET najczęściej wybiera się bibliotekę MediatR. Udostępnia ona kontrakty dla requestów, handlerów i notifications, integrację z dependency injection oraz pipeline behaviors. Aktualne wydania rozwijane w 2026 roku mają także kwestię licencji i konfiguracji klucza, dlatego przed użyciem w projekcie komercyjnym trzeba sprawdzić bieżące warunki pakietu.

Typowy handler z użyciem biblioteki wygląda następująco:

public sealed record CreateOrderCommand(
    int CustomerId,
    IReadOnlyList ProductIds) : IRequest;

public sealed class CreateOrderHandler
    : IRequestHandler
{
    public async Task Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Walidacja reguł biznesowych i zapis zamówienia
        return Guid.NewGuid();
    }
}

Rejestracja sprowadza się zwykle do wskazania assembly zawierającego handlery:

builder.Services.AddMediatR(configuration =>
{
    configuration.RegisterServicesFromAssemblyContaining();
});

Własna implementacja ma sens, gdy potrzebujemy tylko kilku prostych komunikatów, chcemy uniknąć dodatkowej zależności albo zależy nam na pełnej kontroli. Jej koszt pojawia się później, gdy trzeba dopisać obsługę wyjątków, pipeline, powiadomienia, rejestrację generyczną i testy infrastruktury.

Istnieją również biblioteki korzystające z source generatorów, czyli generujące kod dispatchingu podczas kompilacji. Mogą ograniczyć narzut refleksji i ułatwić scenariusze Native AOT. To interesująca opcja dla aplikacji o bardzo wysokich wymaganiach wydajnościowych, ale w większości systemów biznesowych narzut mediatora nie będzie najważniejszym problemem.

Rozwiązanie Kiedy wybrać Największy kompromis
Własna implementacja Mały projekt i niewiele komunikatów Samodzielne utrzymanie infrastruktury
MediatR Standardowy projekt ASP.NET Core z pipeline Dodatkowa zależność i warunki licencyjne
Source-generated mediator Wydajność, AOT i duża liczba komunikatów Więcej decyzji dotyczących konfiguracji

Pipeline behaviors robią największą różnicę

Samo przekierowanie komunikatu do handlera jest użyteczne, ale prawdziwa wartość pojawia się przy pipeline behaviors. To komponenty uruchamiane przed handlerem, po nim albo wokół całej operacji. Przypominają middleware w ASP.NET Core, tylko działają na poziomie komunikatów aplikacyjnych.

W pipeline można umieścić walidację, pomiar czasu, logowanie, obsługę transakcji, autoryzację, cache lub publikowanie metryk. Dzięki temu handler koncentruje się na przypadku użycia, zamiast powtarzać ten sam kod w każdej klasie.

public sealed class LoggingBehavior
    : IPipelineBehavior
    where TRequest : notnull
{
    private readonly ILogger> logger;

    public LoggingBehavior(
        ILogger> logger)
    {
        this.logger = logger;
    }

    public async Task Handle(
        TRequest request,
        RequestHandlerDelegate next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;

        logger.LogInformation(
            "Start request {RequestName}",
            requestName);

        var response = await next();

        logger.LogInformation(
            "End request {RequestName}",
            requestName);

        return response;
    }
}

W praktyce najczęściej zaczynam od dwóch behaviorów: walidacji i telemetryki. Dodawanie kolejnych warstw bez mierzalnej potrzeby prowadzi do potoku, w którym trudno ustalić, skąd wziął się błąd albo opóźnienie.

Powiadomienia i kolejność obsługi

Notification może mieć wielu handlerów. Po opłaceniu zamówienia jeden odbiorca zapisze zdarzenie, drugi wyśle e-mail, a trzeci zaktualizuje read model. To wygodne, ale trzeba pamiętać, że in-process notification nie jest kolejką. Awaria procesu może przerwać obsługę, a retry i trwałość wymagają osobnego mechanizmu, na przykład brokera wiadomości lub outbox pattern.

Nie traktuję też powiadomień jako sposobu na ukrywanie krytycznej logiki biznesowej. Jeśli bez wykonania konkretnej czynności operacja jest niepoprawna, powinna znaleźć się w głównym handlerze albo w jawnie wywołanym serwisie, a nie w przypadkowym odbiorcy zdarzenia.

Kiedy Mediator pomaga, a kiedy komplikuje kod

Mediator dobrze pasuje do aplikacji, w której mamy wiele niezależnych przypadków użycia, rozbudowaną warstwę aplikacyjną i powtarzalne wymagania przekrojowe. Szczególnie dobrze działa w architekturze vertical slice, gdzie kod komendy, handlera, walidacji i DTO znajduje się blisko siebie.

  • Kontrolery mają zbyt wiele zależności i odpowiedzialności.
  • Każda operacja wymaga podobnego logowania, walidacji lub autoryzacji.
  • Zespół chce testować przypadki użycia niezależnie od transportu HTTP.
  • Komendy i zapytania mają wyraźne granice oraz własne reguły biznesowe.

Nie używałbym go automatycznie w małej aplikacji z kilkoma endpointami. Jeżeli kontroler wywołuje jedną metodę serwisu, a mediator dodaje tylko klasę requestu, handler i rejestrację, otrzymujemy więcej plików bez realnego zysku.

Przeczytaj również: var w JavaScripcie - zakres, hoisting i pułapki starego kodu

Typowe błędy projektowe

Pierwszym błędem jest tworzenie handlerów, które są tylko cienką nakładką na repozytorium. Drugi to jeden uniwersalny request obsługujący wiele scenariuszy przez parametry i instrukcje warunkowe. Taki kod szybko staje się trudniejszy w utrzymaniu niż zwykły serwis.

Trzeci problem to ukryte zależności. Handler może wyglądać niewinnie, ale jeśli wywołuje mediator, który publikuje kolejne notifications, a te uruchamiają następne komunikaty, przepływ staje się trudny do prześledzenia. W takich miejscach stosuję jawne granice i dokumentuję, które operacje są synchroniczne, a które mogą zakończyć się później.

Na wydajność patrzę pragmatycznie. Dodatkowe wywołanie, generyki i rozwiązywanie handlera mają koszt, ale w typowej aplikacji biznesowej większe znaczenie będą miały zapytania do bazy, serializacja i sieć. Optymalizacja mediatora ma sens dopiero wtedy, gdy profilowanie pokaże, że rzeczywiście jest wąskim gardłem.

Jak testować rozwiązanie oparte na mediatorze

Największą zaletą testową jest możliwość testowania handlera jako osobnego przypadku użycia. W teście jednostkowym przekazuję mu tylko wymagane zależności, sprawdzam reguły biznesowe i nie uruchamiam całego kontrolera ani serwera HTTP.

Osobno testuję pipeline behaviors. Walidacja powinna mieć testy pokazujące, że niepoprawny request nie dociera do handlera. Behavior transakcyjny powinien być sprawdzony pod kątem zarówno sukcesu, jak i wyjątku. Dzięki temu testy nie kończą się na sprawdzeniu, czy mediator potrafi odnaleźć klasę.

[Fact]
public async Task Handler_should_create_order()
{
    var repository = Substitute.For();
    var handler = new CreateOrderHandler(repository);

    var command = new CreateOrderCommand(
        10,
        new[] { 2, 5 });

    var orderId = await handler.Handle(
        command,
        CancellationToken.None);

    orderId.ShouldNotBe(Guid.Empty);
    await repository.Received(1)
        .Save(Arg.Any(), Arg.Any());
}

Test integracyjny powinien dodatkowo sprawdzić rejestrację handlerów i pełny przepływ przez DI. To ważne, bo część problemów z mediatorem nie wynika z logiki, tylko z nieodnalezionego assembly, złej kolejności behaviorów albo brakującej rejestracji zależności.

Jak podjąć dobrą decyzję dla projektu

Przed dodaniem biblioteki odpowiadam sobie na trzy pytania. Czy mamy wystarczająco dużo przypadków użycia, aby rozdzielenie komunikatów poprawiło orientację w kodzie? Czy pipeline rozwiąże realny problem powtarzalności? I czy zespół zaakceptuje dodatkową warstwę abstrakcji oraz zasady jej stosowania?

  • Mały CRUD: zacznij od kontrolera i serwisu, bez mediatora.
  • Średnia aplikacja biznesowa: rozważ mediator dla komend i zapytań, ale nie dla każdej metody.
  • Duży system: połącz handlerów z vertical slice, behaviorami i jasno opisanymi granicami modułów.
  • Wymagania AOT lub ekstremalna wydajność: porównaj klasyczną bibliotekę z rozwiązaniem generującym kod.

Moja praktyczna zasada jest prosta: mediator powinien sprawić, że przypadki użycia będą łatwiejsze do znalezienia, przetestowania i zmiany. Jeśli po jego wdrożeniu programista musi przejść przez pięć abstrakcji, aby zrozumieć jedną prostą operację, architektura poszła za daleko.

Dobry mediator pozostaje niewidoczny dla logiki biznesowej

Wzorzec Mediator w C# nie jest celem samym w sobie. To narzędzie do porządkowania komunikacji, przypadków użycia i obsługi przekrojowych wymagań. Najlepszy efekt daje wtedy, gdy handlerzy są małe, komunikaty mają jasne znaczenie, a pipeline rozwiązuje powtarzalne problemy bez ukrywania istotnego przepływu.

Jeśli aplikacja rzeczywiście tego potrzebuje, zacznij od jednej komendy, jednego handlera i jednego behavioru. Dopiero po sprawdzeniu, że rozwiązanie poprawia czytelność kodu, rozszerzaj je na kolejne moduły.

FAQ - Najczęstsze pytania

Kontroler tworzy komunikat, a mediator przekazuje go do właściwego handlera. Handler może pobrać dane, wykonać reguły biznesowe, zapisać wynik i opublikować zdarzenie, dzięki czemu kontroler nie musi znać repozytorium, walidatora ani systemu płatności.

Komenda zmienia stan systemu i ma jednego handlera, na przykład CreateInvoiceCommand. Zapytanie odczytuje dane i zwykle zwraca DTO, listę lub wartość. Powiadomienie opisuje zdarzenie i może mieć wielu odbiorców, ale zazwyczaj nie zwraca wyniku.

Własna implementacja pasuje do małego projektu z niewielką liczbą komunikatów. MediatR sprawdzi się w typowej aplikacji ASP.NET Core, szczególnie gdy potrzebne są pipeline behaviors i integracja z DI, ale przed użyciem komercyjnym trzeba sprawdzić bieżące warunki licencji. Rozwiązanie generujące kod warto porównać przy wymaganiach AOT, dużej liczbie komunikatów lub wysokiej wydajności.

Pipeline behaviors mogą obsługiwać walidację, logowanie, telemetrykę, transakcje, autoryzację, cache i metryki. Powiadomienia obsługiwane w procesie aplikacji nie zapewniają trwałości ani automatycznego retry, dlatego w krytycznych scenariuszach potrzebny jest osobny broker wiadomości lub outbox pattern.

Handler warto testować jednostkowo z użyciem tylko wymaganych zależności, sprawdzając reguły biznesowe i wynik operacji. Osobne testy powinny potwierdzać, że walidacja zatrzymuje niepoprawny request przed handlerem, a test integracyjny sprawdza rejestrację handlerów, behaviorów i zależności w DI.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

pipeline
mediatr
cqrs
handlery
vertical slice
Autor Przemysław Kwiatkowski
Przemysław Kwiatkowski
Jestem Przemysław i od 15 lat zajmuję się programowaniem .NET, chmurą Azure oraz sztuczną inteligencją. Moja przygoda z tymi technologiami zaczęła się od fascynacji możliwościami, jakie dają, a z czasem przerodziła się w pasję do tworzenia rozwiązań, które realnie wpływają na pracę i życie ludzi. Na kursdotnet.pl staram się dzielić się swoją wiedzą w sposób przystępny, tłumacząc złożone zagadnienia i pomagając zrozumieć, jak te dynamicznie rozwijające się obszary IT mogą być wykorzystane w praktyce. Dokładam wszelkich starań, aby prezentowane przeze mnie materiały były rzetelne, aktualne i oparte na sprawdzonych źródłach, a także aby uporządkować wiedzę w sposób ułatwiający jej przyswojenie.

Udostępnij artykuł

Napisz komentarz