Start Debugging

Переход с MediatR на простое внедрение зависимостей в .NET 11

Пошаговый чек-лист для удаления MediatR 12-14 и замены обработчиков IRequest, ISender, pipeline behaviors и INotification на простые классы-сервисы и внедрение через конструктор.

Удаление MediatR из кодовой базы на .NET 11 — это механический рефакторинг, а не переписывание. Для типичного сервиса с 50-150 обработчиками заложите от половины дня до дня: большинство обработчиков один к одному сворачиваются в методы простых классов-сервисов, вызовы ISender.Send превращаются в прямые вызовы интерфейса, и единственная часть, требующая проектного осмысления, — это конвейер. Ломается всё, что опиралось на разрешение обработчиков в runtime или на обёртывание каждого запроса через IPipelineBehavior<,>; это становится внедрением через конструктор и декораторами. Это стоит делать, если вы только вызываете Send, если вы выше границы выручки в $5,000,000 редакции Community у MediatR и не хотите покупать коммерческую лицензию, или если вам нужны Native AOT и совместимость с trimming. Это не стоит делать, если ваши pipeline behaviors несут нагрузку на сотнях типов запросов.

Упомянутые версии: это руководство охватывает удаление MediatR 12.5.0 (последний выпуск под лицензией Apache 2.0), 13.0 (выпущен 2025-07-02, первый выпуск под двойной моделью Reciprocal Public License 1.5 / коммерческая от Lucky Penny Software) и текущую линейку 14.x. Замещающий код нацелен на <TargetFramework>net11.0</TargetFramework> с SDK .NET 11 и C# 14, плюс Scrutor 6.x для декораторов. Если вы ещё решаете, стоит ли уходить, сначала прочитайте MediatR против простых классов-сервисов в 2026; эта статья предполагает, что решение уже принято.

Почему команды удаляют MediatR именно сейчас

Что ломается

ОбластьИзменениеСерьёзность
Внедрение ISender / IMediatorЗаменяется на конкретный интерфейс сервиса, внедряемый напрямуювысокая
IRequest<T> + IRequestHandler<,>Сворачиваются в один интерфейс + метод реализациивысокая
IPipelineBehavior<,>Нет обобщённой точки обёртывания; заменяется по интерфейсу декораторами или middlewareвысокая
INotification + INotificationHandler<>Заменяется внедрённым IEnumerable<IHandler> для fan-out или агрегатором событийсредняя
Регистрация AddMediatR(...)Заменяется явными вызовами AddScoped (или одним сканированием Scrutor)средняя
RequestHandlerDelegate<T> в behaviorsНет эквивалента; цепочка “next” исчезаетсредняя
ISender.CreateStream (IStreamRequest)Заменяется методом, возвращающим IAsyncEnumerable<T>низкая
Модульные тесты, мокающие ISenderПеренаправляются на мок конкретного интерфейсанизкая

Предварительный чек-лист

# .NET 11 - inventory MediatR usage before touching anything
grep -rn "IRequestHandler\|IRequest<\|: IRequest" src/      # handlers + requests
grep -rn "IPipelineBehavior" src/                            # the hard part
grep -rn "INotification" src/                                # fan-out
grep -rn "ISender\|IMediator\|\.Send(\|\.Publish(" src/      # call sites

Шаги миграции

  1. Преобразуйте каждый запрос и обработчик в интерфейс сервиса и метод.

Запрос MediatR — это тип сообщения плюс отдельный обработчик. Замените оба одним интерфейсом и одной реализацией. Группируйте связанные запросы в один сервис, вместо того чтобы создавать по интерфейсу на каждый старый запрос.

// .NET 11, C# 14, MediatR 14.x - BEFORE
using MediatR;

public record GetOrderById(int OrderId) : IRequest<OrderDto>;

public sealed class GetOrderByIdHandler(AppDbContext db)
    : IRequestHandler<GetOrderById, OrderDto>
{
    public async Task<OrderDto> Handle(GetOrderById request, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([request.OrderId], ct)
            ?? throw new OrderNotFoundException(request.OrderId);
        return order.ToDto();
    }
}
// .NET 11, C# 14 - AFTER: plain service, no MediatR reference
public interface IOrderService
{
    Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct);
}

public sealed class OrderService(AppDbContext db) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        var order = await db.Orders.FindAsync([orderId], ct)
            ?? throw new OrderNotFoundException(orderId);
        return order.ToDto();
    }
}

Проверьте: в новом файле нет using MediatR;, а тело обработчика побайтово идентично, не считая сигнатуры метода. Параметры record запроса становятся параметрами метода в том же порядке.

  1. Замените места вызова ISender.Send прямыми вызовами интерфейса.

Каждый вызывающий код, который внедрял ISender или IMediator, теперь внедряет конкретный интерфейс сервиса и вызывает метод.

// .NET 11, C# 14 - BEFORE
public async Task<OrderDto> Get(int id, ISender sender, CancellationToken ct)
    => await sender.Send(new GetOrderById(id), ct);

// AFTER
public async Task<OrderDto> Get(int id, IOrderService orders, CancellationToken ct)
    => await orders.GetByIdAsync(id, ct);

Проверьте: после этого шага grep -rn "ISender\|IMediator" src/ возвращает только строки, которые вы намеренно ещё не мигрировали. Счётчик должен двигаться к нулю.

  1. Зарегистрируйте сервисы явно.

Удалите AddMediatR(...) и зарегистрируйте каждый сервис. Явная регистрация безопасна для trimming и падает быстро при запуске, что и есть тот режим отказа, который стоит за Unable to resolve service for type while attempting to activate.

// .NET 11, C# 14 - BEFORE
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssemblyContaining<GetOrderById>());

// AFTER - explicit, or one Scrutor scan if you prefer convention
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();

Если вам нужна регистрация по соглашению без рефлексионной модели MediatR, Scrutor сканирует по имени интерфейса:

// .NET 11, C# 14, Scrutor 6.x - register every *Service against its interface
builder.Services.Scan(scan => scan
    .FromAssemblyOf<OrderService>()
    .AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Проверьте: приложение запускается. Запустите его и обратитесь к мигрированному endpoint; отсутствующая регистрация теперь падает при запуске, а не на первом запросе. Убедитесь, что не закрались ошибки scoped-из-singleton — класс багов, разобранный в Cannot consume scoped service from singleton.

  1. Замените pipeline behaviors декораторами или middleware.

Это единственный шаг, требующий рассуждения. IPipelineBehavior<,> обёртывал каждый запрос в одном месте. У вас есть две честные замены.

Для сквозных задач, которые действительно относятся к отдельному сервису (журналирование, кеширование, повторы), используйте декоратор Scrutor:

// .NET 11, C# 14, Scrutor 6.x - BEFORE was a generic ValidationBehavior<TRequest,TResponse>
public sealed class LoggingOrderService(
    IOrderService inner,
    ILogger<LoggingOrderService> logger) : IOrderService
{
    public async Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
    {
        logger.LogInformation("Fetching order {OrderId}", orderId);
        return await inner.GetByIdAsync(orderId, ct);
    }
}

// Registration: wrap the real implementation
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.Decorate<IOrderService, LoggingOrderService>();

Для задач, которые на самом деле относятся к HTTP (журналирование запросов, отображение исключений в ProblemDetails, аутентификация), полностью вынесите их из слоя приложения в middleware ASP.NET Core или фильтр endpoint. Behavior, который перехватывал исключения и формировал ответы, относится к тому же месту, что и глобальный фильтр исключений в ASP.NET Core 11, а не к декоратору.

Если вы использовали FluentValidation внутри ValidationBehavior, сохраните валидаторы и вызывайте их из одного декоратора или из фильтра endpoint minimal API:

// .NET 11, C# 14 - a validation decorator replacing ValidationBehavior for one interface
public sealed class ValidatingOrderService(
    IOrderService inner,
    IValidator<CreateOrder> validator) : IOrderService
{
    public async Task<OrderDto> CreateAsync(CreateOrder cmd, CancellationToken ct)
    {
        await validator.ValidateAndThrowAsync(cmd, ct);
        return await inner.CreateAsync(cmd, ct);
    }

    public Task<OrderDto> GetByIdAsync(int orderId, CancellationToken ct)
        => inner.GetByIdAsync(orderId, ct);
}

Проверьте: напишите или сохраните тест, утверждающий, что сквозная задача по-прежнему выполняется, например что недопустимая команда бросает ValidationException до обращения к базе данных. Выполните dotnet test и подтвердите, что тесты behavior проходят.

  1. Замените fan-out INotification.

Publish у MediatR вызывал каждый INotificationHandler<T>. Замените его внедрённым IEnumerable<T> небольшого интерфейса обработчика, который вы определяете, и тонким издателем.

// .NET 11, C# 14 - BEFORE: INotification + handlers, AFTER: explicit fan-out
public interface IOrderPlacedHandler
{
    Task HandleAsync(OrderPlaced evt, CancellationToken ct);
}

public sealed class OrderEventPublisher(IEnumerable<IOrderPlacedHandler> handlers)
{
    public async Task PublishAsync(OrderPlaced evt, CancellationToken ct)
    {
        foreach (var handler in handlers)
            await handler.HandleAsync(evt, ct);
    }
}

// Registration - each handler registered against the interface
builder.Services.AddScoped<IOrderPlacedHandler, SendConfirmationEmail>();
builder.Services.AddScoped<IOrderPlacedHandler, UpdateInventory>();
builder.Services.AddScoped<OrderEventPublisher>();

Контейнер передаёт вам каждый зарегистрированный обработчик в IEnumerable<IOrderPlacedHandler>, так что добавление обработчика — одна строка регистрации, та же эргономика, что давал MediatR. Если вы публикуете много типов событий, сгенерируйте издателя с обобщённым IEventPublisher<T> вместо одного на каждое событие.

Проверьте: утвердите, что публикация события вызывает все зарегистрированные обработчики. Достаточно теста с двумя фейковыми обработчиками, каждый из которых записывает вызов.

  1. Удалите пакет и последние ссылки.

Когда места вызова исчезли, уберите зависимость.

# .NET 11 - remove the package from every project that referenced it
dotnet remove package MediatR
grep -rn "MediatR" src/ || echo "clean"

Проверьте: grep -rn "MediatR" src/ печатает clean. В сборке нет неразрешённого using MediatR;, и dotnet build -c Release завершается успешно без предупреждений об отсутствующем пакете.

Проверка: дымовой тест после миграции

Пройдите этот список сверху вниз и не пропускайте строку о производительности:

План отката

Эта миграция обратима покоммитно, но утомительна для отката целиком, поэтому делайте её поэтапно. Держите каждый из шести шагов отдельным коммитом и мигрируйте по одному вертикальному срезу за раз (один интерфейс сервиса и его вызывающий код), а не всё решение сразу. MediatR и простые сервисы могут сосуществовать во время перехода: оставьте AddMediatR зарегистрированным для немигрированных обработчиков, пока переводите остальное. Если срез ведёт себя неправильно, откатите коммиты этого среза, и остальное приложение не пострадает. Здесь нет изменений данных или схемы, так что откат — это чисто откат кода без миграции, которую нужно отменять.

Подводные камни, на которые мы наткнулись

Порядок IPipelineBehavior не отображается на декораторы напрямую. MediatR выполняет behaviors в порядке регистрации вокруг одного обработчика. Декораторы Scrutor вкладываются в порядке вызова Decorate, и последний зарегистрированный декоратор — самая внешняя обёртка. Ошибитесь в порядке — и ваш декоратор журналирования выполнится внутри декоратора валидации, а не вокруг него. Напишите по тесту порядка на каждый декорированный интерфейс.

ISender внутри обработчика, вызывающего другой обработчик, становится прямым вызовом сервиса, и это может вскрыть цикл. Косвенность MediatR скрывала зависимости между обработчиками. Когда OrderService внедряет ICustomerService, а CustomerService внедряет IOrderService, контейнер падает при запуске с циклической зависимостью. Это миграция выносит на поверхность проблему проектирования, которую MediatR маскировал; разорвите цикл, вынеся общую логику в третий сервис.

Потоковые запросы требуют IAsyncEnumerable<T>, а не Task<T>. Если вы использовали IStreamRequest<T> и CreateStream, замещающий метод возвращает IAsyncEnumerable<T> и использует yield return. Не сворачивайте его в Task<List<T>>; это меняет семантику потоковой передачи и может исчерпать память на больших результатах — та же ловушка, что описана в чтении большого CSV в .NET 11 без нехватки памяти.

Тесты, мокавшие ISender, молча проходят впустую. Тест, который настраивал sender.Send(...) на возврат значения, выдаст ошибку компиляции, как только ISender исчезнет, и это хорошо. Но тест, который внедрял настоящий IMediator и утверждал поведение через него, нужно перенаправить на конкретный интерфейс. Перезапустите весь набор, не доверяйте одной только зелёной сборке.

Это та самая рационализация зависимостей, которая окупается так же, как выбор встроенного сериализатора в System.Text.Json против Newtonsoft.Json в 2026: на одну стороннюю библиотеку меньше на горячем пути, на один вопрос лицензирования меньше и код, по которому новый коллега может ориентироваться, не изучая сначала соглашение о диспетчеризации.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад