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.

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.
